Files
JQC_multi_tenant/tests/test_schedule_recurrence.py
T

474 lines
18 KiB
Python

"""
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'