200 lines
7.1 KiB
Python
200 lines
7.1 KiB
Python
"""
|
|
tests/test_sla_candidate_query.py
|
|
---------------------------------
|
|
Equivalence test for the SQL prefilter in send_sla_alerts().
|
|
|
|
The cron used to read EVERY open issue and decide in Python which ones deserved
|
|
a notification. It now narrows to candidates in SQL first.
|
|
|
|
The contract is a CONSERVATIVE SUPERSET, not equality:
|
|
|
|
* Nothing the old loop would have notified may be missed. This is the safety
|
|
property — a miss is a silently unsent SLA alert, with nothing in any log
|
|
to say it did not happen.
|
|
* The SQL may return extra rows, because it deliberately does not replicate
|
|
the "already notified at_risk and still only at_risk" skip (that would mean
|
|
writing the per-severity deadline arithmetic a second time, in SQL). The
|
|
Python loop still applies that skip, so no extra notification is ever sent
|
|
— only a handful of extra rows are read.
|
|
|
|
The reference predicate below is the old loop's skip logic, written out.
|
|
|
|
Multi-tenant note: the cron runs once per tenant database, so this prefilter is
|
|
worth more here than in ST — the saving multiplies by the tenant count.
|
|
"""
|
|
|
|
from datetime import timedelta
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def ctx(app):
|
|
with app.app_context():
|
|
from app import db
|
|
db.drop_all()
|
|
db.create_all()
|
|
yield app
|
|
db.session.remove()
|
|
|
|
|
|
def _candidate_query():
|
|
"""The prefilter exactly as send_sla_alerts() builds it."""
|
|
from app import db
|
|
from app.models.issue import Issue
|
|
from app.utils.sla import SLA_HOURS, AT_RISK_THRESHOLD
|
|
from app.utils.time_utils import now_eastern
|
|
|
|
now = now_eastern()
|
|
age_clauses = [
|
|
db.and_(
|
|
Issue.severity == severity,
|
|
Issue.reported_at <= now - timedelta(hours=hours * AT_RISK_THRESHOLD),
|
|
)
|
|
for severity, hours in SLA_HOURS.items()
|
|
]
|
|
return Issue.query.filter(
|
|
Issue.status.in_(['open', 'in_progress', 'pending_verification']),
|
|
Issue.reported_at.isnot(None),
|
|
db.or_(Issue.sla_notified.is_(None), Issue.sla_notified != 'breached'),
|
|
db.or_(*age_clauses),
|
|
)
|
|
|
|
|
|
def _old_loop_would_act(issue):
|
|
"""The pre-change Python logic: which rows survived to send a notification."""
|
|
from app.utils.sla import sla_status
|
|
|
|
if issue.status not in ('open', 'in_progress', 'pending_verification'):
|
|
return False
|
|
if issue.reported_at is None:
|
|
return False # would have raised TypeError — see the NULL test
|
|
status = sla_status(issue)
|
|
if status not in ('at_risk', 'breached'):
|
|
return False
|
|
already = issue.sla_notified
|
|
if already == 'breached':
|
|
return False
|
|
if already == 'at_risk' and status == 'at_risk':
|
|
return False
|
|
return True
|
|
|
|
|
|
def _add(severity, hours_ago, status='open', sla_notified=None):
|
|
from app import db
|
|
from app.models.issue import Issue
|
|
from app.utils.time_utils import now_eastern
|
|
|
|
issue = Issue(
|
|
severity=severity,
|
|
description='x',
|
|
status=status,
|
|
sla_notified=sla_notified,
|
|
reported_at=now_eastern() - timedelta(hours=hours_ago),
|
|
)
|
|
db.session.add(issue)
|
|
return issue
|
|
|
|
|
|
@pytest.fixture
|
|
def population(ctx):
|
|
"""One issue per interesting combination of severity / age / state."""
|
|
from app import db
|
|
from app.utils.sla import SLA_HOURS, AT_RISK_THRESHOLD
|
|
|
|
rows = []
|
|
for severity, window in SLA_HOURS.items():
|
|
at_risk_at = window * AT_RISK_THRESHOLD
|
|
rows += [
|
|
_add(severity, 0), # fresh -> ok
|
|
_add(severity, at_risk_at * 0.5), # halfway -> ok
|
|
_add(severity, at_risk_at + 1), # at risk
|
|
_add(severity, window + 1), # breached
|
|
# already-notified variants
|
|
_add(severity, at_risk_at + 1, sla_notified='at_risk'),
|
|
_add(severity, window + 1, sla_notified='at_risk'),
|
|
_add(severity, window + 1, sla_notified='breached'),
|
|
# non-actionable / other statuses
|
|
_add(severity, window + 1, status='resolved'),
|
|
_add(severity, window + 1, status='in_progress'),
|
|
_add(severity, window + 1, status='pending_verification'),
|
|
]
|
|
db.session.commit()
|
|
return rows
|
|
|
|
|
|
def test_prefilter_never_misses_an_actionable_issue(population):
|
|
"""Safety property: every row the old loop notified is still selected."""
|
|
expected = {i.id for i in population if _old_loop_would_act(i)}
|
|
actual = {i.id for i in _candidate_query().all()}
|
|
|
|
assert expected, 'fixture built no actionable issues — test is vacuous'
|
|
assert expected <= actual, (
|
|
'the prefilter drops issues the old loop would have alerted on: '
|
|
f'{sorted(expected - actual)}'
|
|
)
|
|
|
|
|
|
def test_prefilter_extras_are_all_skipped_by_the_loop(population):
|
|
"""The extra rows the SQL lets through produce no extra notifications.
|
|
|
|
Each must be a row the Python loop independently skips, so the set of alerts
|
|
actually sent is unchanged.
|
|
"""
|
|
from app.utils.sla import sla_status
|
|
|
|
by_id = {i.id: i for i in population}
|
|
expected = {i.id for i in population if _old_loop_would_act(i)}
|
|
extras = {i.id for i in _candidate_query().all()} - expected
|
|
|
|
for iid in extras:
|
|
issue = by_id[iid]
|
|
assert not _old_loop_would_act(issue)
|
|
# and the only reason it is allowed through is the at_risk bookkeeping
|
|
assert issue.sla_notified == 'at_risk' and sla_status(issue) == 'at_risk', (
|
|
f'issue {iid} is an unexplained extra: status={issue.status} '
|
|
f'severity={issue.severity} sla_notified={issue.sla_notified} '
|
|
f'sla_status={sla_status(issue)}'
|
|
)
|
|
|
|
|
|
def test_prefilter_excludes_the_bulk_of_open_issues(population):
|
|
"""The point of the change: most open issues are never read."""
|
|
from app.models.issue import Issue
|
|
|
|
total_open = Issue.query.filter(
|
|
Issue.status.in_(['open', 'in_progress', 'pending_verification'])
|
|
).count()
|
|
candidates = _candidate_query().count()
|
|
assert candidates < total_open
|
|
|
|
|
|
def test_null_reported_at_is_excluded_not_crashed(ctx):
|
|
"""reported_at is nullable and sla_status() raises TypeError on NULL.
|
|
|
|
Before the prefilter one such row aborted the whole cron run — and in MT it
|
|
would abort that tenant's run entirely. It must now be excluded in SQL.
|
|
"""
|
|
from app import db
|
|
from app.models.issue import Issue
|
|
from app.utils.sla import sla_status
|
|
|
|
issue = Issue(severity='high', description='x', status='open')
|
|
db.session.add(issue)
|
|
db.session.commit()
|
|
|
|
# The column carries default=now_eastern, so an ORM insert can never leave
|
|
# it NULL — the row has to be forced, which is exactly how a legacy or
|
|
# raw-SQL-inserted row would look.
|
|
db.session.execute(
|
|
db.text('UPDATE issues SET reported_at = NULL WHERE id = :i'),
|
|
{'i': issue.id},
|
|
)
|
|
db.session.commit()
|
|
db.session.expire(issue)
|
|
|
|
assert issue.reported_at is None
|
|
assert issue.id not in {i.id for i in _candidate_query().all()}
|
|
with pytest.raises(TypeError):
|
|
sla_status(issue) # confirms the crash the filter is avoiding
|