Sep 4 - Add link relavant issues function

This commit is contained in:
2026-09-04 16:53:53 -04:00
parent d291dfc513
commit e9005b9b9c
20 changed files with 3208 additions and 72 deletions
+501
View File
@@ -0,0 +1,501 @@
"""
tests/test_issue_links.py
-------------------------
phase57 — link a duplicate to its original, or two issues about the same thing.
Two things are being pinned here.
**The direction convention.** One row is stored per pair and shown on BOTH
issues, so the same row has to read differently at each end ("Duplicate of #B"
on one, "Duplicated by #A" on the other). That is what makes `exists_between()`
necessary: `(A,B)` and `(B,A)` are distinct rows to the database but the same
link to a person, and the UniqueConstraint only covers the stored direction.
**Scope, which is the part that actually matters.** A link is a pointer that
exposes the far issue's id, description, facility and status. Three surfaces
have to hold the line and only one of them is a real boundary:
_readable_links() filters what the panel RENDERS
link_search() scopes what the picker FINDS
add_link() re-checks on POST — the search is a convenience
If any one of them is unfiltered, a customer reads an issue at a facility they
hold no assignment to, simply because one of our staff linked it.
Cross-TENANT isolation is not tested here and cannot be: `issue_links` lives in
the tenant database and every query routes through RoutingSession, so an id
from another tenant does not resolve at all. See tests/test_tenant_isolation.py
for that layer. What follows is scope WITHIN one tenant.
"""
import pytest
@pytest.fixture
def client(app):
with app.app_context():
from app import db
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
# ── Builders ─────────────────────────────────────────────────────────────────
_n = {'i': 0}
def _uniq():
_n['i'] += 1
return _n['i']
def _user(role='admin', **kw):
from app import db
from app.models.user import User
n = _uniq()
u = User(username=kw.pop('username', f'{role}{n}'),
full_name=f'{role.title()} {n}',
email=f'{role}{n}@example.com',
role=role, active=True, password_set=True)
u.set_password('pw-correct1')
for k, v in kw.items():
setattr(u, k, v)
db.session.add(u)
db.session.commit()
return u
def _project(name=None):
from app import db
from app.models.project import Project
p = Project(name=name or f'Contract {_uniq()}', active=True)
db.session.add(p)
db.session.commit()
return p
def _facility(project=None, name=None):
from app import db
from app.models.facility import Facility
f = Facility(name=name or f'Facility {_uniq()}', active=True,
project_id=project.id if project else None)
db.session.add(f)
db.session.commit()
return f
def _issue(facility=None, description='Test issue', **kw):
from app import db
from app.models.issue import Issue
from app.utils.time_utils import now_eastern
i = Issue(facility_id=facility.id if facility else None,
description=description,
severity=kw.pop('severity', 'medium'),
status=kw.pop('status', 'open'),
reported_at=kw.pop('reported_at', now_eastern()))
for k, v in kw.items():
setattr(i, k, v)
db.session.add(i)
db.session.commit()
return i
def _link(a, b, link_type='related'):
from app import db
from app.models.issue import IssueLink
link = IssueLink(issue_id=a.id, linked_issue_id=b.id, link_type=link_type)
db.session.add(link)
db.session.commit()
return link
def _assign_inspector(user, project):
from app import db
from app.models.inspector_assignment import InspectorAssignment
db.session.add(InspectorAssignment(user_id=user.id, project_id=project.id))
db.session.commit()
def _assign_customer(user, project):
from app import db
from app.models.project import CustomerAssignment
db.session.add(CustomerAssignment(user_id=user.id, project_id=project.id))
db.session.commit()
def _login(client, user):
return client.post('/auth/login',
data={'username': user.username, 'password': 'pw-correct1'},
follow_redirects=False)
def _link_count():
from app.models.issue import IssueLink
return IssueLink.query.count()
# ── Model semantics ──────────────────────────────────────────────────────────
def test_a_link_reads_differently_from_each_end(client):
"""The stored direction carries meaning for 'duplicate'."""
a, b = _issue(description='dupe'), _issue(description='original')
link = _link(a, b, 'duplicate')
assert link.label_for(a.id) == 'Duplicate of'
assert link.label_for(b.id) == 'Duplicated by'
assert link.other_issue(a.id).id == b.id
assert link.other_issue(b.id).id == a.id
def test_related_reads_the_same_from_both_ends(client):
a, b = _issue(), _issue()
link = _link(a, b, 'related')
assert link.label_for(a.id) == 'Related to'
assert link.label_for(b.id) == 'Related to'
def test_one_row_appears_on_both_issues(client):
"""all_links() merges the two storage directions into one list."""
a, b = _issue(), _issue()
_link(a, b)
assert _link_count() == 1
assert len(a.all_links()) == 1
assert len(b.all_links()) == 1
def test_exists_between_is_direction_agnostic(client):
"""The UniqueConstraint only covers the stored direction; this covers both."""
from app.models.issue import IssueLink
a, b = _issue(), _issue()
_link(a, b)
assert IssueLink.exists_between(a.id, b.id)
assert IssueLink.exists_between(b.id, a.id)
assert not IssueLink.exists_between(a.id, _issue().id)
def test_deleting_an_issue_removes_its_links_from_both_sides(client):
"""A surviving link would render a dead row on the other issue's page."""
from app import db
a, b, c = _issue(), _issue(), _issue()
_link(a, b) # a is the source
_link(c, a) # a is the target
assert _link_count() == 2
db.session.delete(a)
db.session.commit()
assert _link_count() == 0
# ── Creating links through the route ─────────────────────────────────────────
def test_admin_can_link_two_issues(client):
admin = _user('admin')
a, b = _issue(), _issue()
_login(client, admin)
client.post(f'/issues/{a.id}/links',
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
follow_redirects=True)
from app.models.issue import IssueLink
link = IssueLink.query.one()
assert (link.issue_id, link.linked_issue_id) == (a.id, b.id)
assert link.link_type == 'duplicate'
assert link.created_by == admin.id
def test_a_leading_hash_is_accepted(client):
"""People type the issue number the way it is displayed."""
_login(client, _user('admin'))
a, b = _issue(), _issue()
client.post(f'/issues/{a.id}/links',
data={'link_type': 'related', 'linked_issue_id': f'#{b.id}'},
follow_redirects=True)
assert _link_count() == 1
def test_linking_does_not_touch_either_issue(client):
"""A link is PURELY NAVIGATIONAL — no status, SLA, assignee or follower."""
from app import db
from app.models.issue import Issue, IssueFollower
_login(client, _user('admin'))
a = _issue(status='open', severity='high')
b = _issue(status='in_progress', severity='low')
before = [(i.id, i.status, i.severity, i.assigned_to, i.resolved_at,
i.sla_notified) for i in (a, b)]
client.post(f'/issues/{a.id}/links',
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
follow_redirects=True)
# db.session.get, not Model.query.get — SQLAlchemy 2.x (CLAUDE.md rule 11).
after = [(i.id, i.status, i.severity, i.assigned_to, i.resolved_at,
i.sla_notified)
for i in (db.session.get(Issue, a.id), db.session.get(Issue, b.id))]
assert before == after
assert IssueFollower.query.count() == 0
@pytest.mark.parametrize('payload', [
{'link_type': 'related', 'linked_issue_id': 'not-a-number'},
{'link_type': 'related', 'linked_issue_id': ''},
{'link_type': 'nonsense', 'linked_issue_id': '1'},
{'linked_issue_id': '1'},
])
def test_malformed_link_requests_are_rejected_without_error(client, payload):
_login(client, _user('admin'))
a = _issue()
res = client.post(f'/issues/{a.id}/links', data=payload,
follow_redirects=True)
assert res.status_code == 200
assert _link_count() == 0
def test_an_issue_cannot_be_linked_to_itself(client):
_login(client, _user('admin'))
a = _issue()
client.post(f'/issues/{a.id}/links',
data={'link_type': 'related', 'linked_issue_id': str(a.id)},
follow_redirects=True)
assert _link_count() == 0
def test_the_same_pair_cannot_be_linked_twice_in_either_direction(client):
"""The reverse direction is the case the UniqueConstraint cannot catch."""
_login(client, _user('admin'))
a, b = _issue(), _issue()
client.post(f'/issues/{a.id}/links',
data={'link_type': 'related', 'linked_issue_id': str(b.id)},
follow_redirects=True)
client.post(f'/issues/{b.id}/links',
data={'link_type': 'duplicate', 'linked_issue_id': str(a.id)},
follow_redirects=True)
assert _link_count() == 1
def test_unlinking_works_from_either_end(client):
_login(client, _user('admin'))
a, b = _issue(), _issue()
link = _link(a, b)
# From the far end — the row is stored on a, deleted from b's page.
client.post(f'/issues/{b.id}/links/{link.id}/delete', follow_redirects=True)
assert _link_count() == 0
def test_cannot_delete_a_link_between_two_other_issues(client):
"""The link must actually touch the issue named in the URL."""
_login(client, _user('admin'))
a, b, unrelated = _issue(), _issue(), _issue()
link = _link(a, b)
client.post(f'/issues/{unrelated.id}/links/{link.id}/delete',
follow_redirects=True)
assert _link_count() == 1
# ── Permission ───────────────────────────────────────────────────────────────
@pytest.mark.parametrize('role', ['admin', 'director', 'auditor'])
def test_issue_managers_may_link(client, role):
_login(client, _user(role))
a, b = _issue(), _issue()
client.post(f'/issues/{a.id}/links',
data={'link_type': 'related', 'linked_issue_id': str(b.id)},
follow_redirects=True)
assert _link_count() == 1
def test_the_assignee_may_link_their_own_issue(client):
"""_can_manage_links mirrors the page's can_edit, which includes the assignee."""
project = _project()
facility = _facility(project)
inspector = _user('inspector')
_assign_inspector(inspector, project)
a = _issue(facility=facility, assigned_to=inspector.id)
b = _issue(facility=facility)
_login(client, inspector)
client.post(f'/issues/{a.id}/links',
data={'link_type': 'related', 'linked_issue_id': str(b.id)},
follow_redirects=True)
assert _link_count() == 1
def test_a_project_manager_may_not_link(client):
"""Widening this is a one-line change — but change view.html's can_edit too."""
_login(client, _user('project_manager'))
a, b = _issue(), _issue()
res = client.post(f'/issues/{a.id}/links',
data={'link_type': 'related', 'linked_issue_id': str(b.id)})
assert res.status_code == 403
assert _link_count() == 0
def test_a_customer_may_not_link_even_on_their_own_facility(client):
project = _project()
facility = _facility(project)
customer = _user('customer')
_assign_customer(customer, project)
a = _issue(facility=facility)
b = _issue(facility=facility)
_login(client, customer)
res = client.post(f'/issues/{a.id}/links',
data={'link_type': 'related', 'linked_issue_id': str(b.id)})
assert res.status_code == 403
assert _link_count() == 0
# ── Scope: the part that actually matters ────────────────────────────────────
def test_cannot_link_to_an_issue_outside_your_scope(client):
"""An inspector must not be able to attach another contract's issue."""
mine, theirs = _project('Mine'), _project('Theirs')
my_facility, their_facility = _facility(mine), _facility(theirs)
inspector = _user('inspector')
_assign_inspector(inspector, mine)
a = _issue(facility=my_facility, assigned_to=inspector.id)
out_of_scope = _issue(facility=their_facility, description='another contract')
_login(client, inspector)
res = client.post(f'/issues/{a.id}/links',
data={'link_type': 'related',
'linked_issue_id': str(out_of_scope.id)},
follow_redirects=True)
assert _link_count() == 0
# And the refusal must not confirm the issue exists.
assert 'another contract' not in res.get_data(as_text=True)
def test_the_panel_hides_a_link_whose_far_end_is_out_of_scope(client):
"""An admin can link across contracts. A customer at one end must still not
read the issue at the other."""
mine, theirs = _project('Mine'), _project('Theirs')
my_facility, their_facility = _facility(mine), _facility(theirs)
customer = _user('customer')
_assign_customer(customer, mine)
visible = _issue(facility=my_facility, description='my own issue')
hidden = _issue(facility=their_facility,
description='SECRET other customer issue')
_link(visible, hidden)
_login(client, customer)
body = client.get(f'/issues/{visible.id}').get_data(as_text=True)
assert 'my own issue' in body
assert 'SECRET other customer issue' not in body
assert f'/issues/{hidden.id}' not in body
def test_link_search_is_scoped(client):
mine, theirs = _project('Mine'), _project('Theirs')
my_facility, their_facility = _facility(mine), _facility(theirs)
inspector = _user('inspector')
_assign_inspector(inspector, mine)
a = _issue(facility=my_facility, description='mine mine mine')
_issue(facility=my_facility, description='mine also')
_issue(facility=their_facility, description='mine but theirs')
_login(client, inspector)
results = client.get(f'/issues/{a.id}/link-search?q=mine').get_json()['results']
descriptions = {r['description'] for r in results}
assert 'mine also' in descriptions
assert 'mine but theirs' not in descriptions
def test_link_search_excludes_self_and_already_linked(client):
_login(client, _user('admin'))
a = _issue(description='alpha one')
b = _issue(description='alpha two')
_link(a, b)
ids = {r['id'] for r in
client.get(f'/issues/{a.id}/link-search?q=alpha').get_json()['results']}
assert a.id not in ids
assert b.id not in ids
def test_link_search_finds_by_issue_number(client):
_login(client, _user('admin'))
a = _issue(description='alpha')
b = _issue(description='beta')
ids = {r['id'] for r in
client.get(f'/issues/{a.id}/link-search?q={b.id}').get_json()['results']}
assert b.id in ids
def test_link_search_returns_nothing_for_an_unscoped_inspector(client):
"""Empty scope means no access to anything — rule 57's fail-closed default."""
inspector = _user('inspector') # no InspectorAssignment rows
a = _issue(facility=_facility(_project()), assigned_to=inspector.id)
_login(client, inspector)
res = client.get(f'/issues/{a.id}/link-search?q=a')
# Either the issue itself is unreachable (403) or the search finds nothing.
assert res.status_code == 403 or res.get_json()['results'] == []
# ── Rendering ────────────────────────────────────────────────────────────────
def test_the_panel_renders_both_directions(client):
_login(client, _user('admin'))
original = _issue(description='the original')
dupe = _issue(description='the duplicate')
_link(dupe, original, 'duplicate')
on_dupe = client.get(f'/issues/{dupe.id}').get_data(as_text=True)
on_original = client.get(f'/issues/{original.id}').get_data(as_text=True)
assert 'Duplicate of' in on_dupe
assert f'/issues/{original.id}' in on_dupe
assert 'Duplicated by' in on_original
assert f'/issues/{dupe.id}' in on_original
def test_the_panel_renders_with_no_links(client):
_login(client, _user('admin'))
a = _issue()
body = client.get(f'/issues/{a.id}').get_data(as_text=True)
assert 'Linked Issues' in body
assert 'No linked issues.' in body
+341
View File
@@ -0,0 +1,341 @@
"""
tests/test_scoped_pages_smoke.py
--------------------------------
Smoke coverage for the pages whose queries were rewritten during the database
optimization pass: the dashboard, the issues list, and the reports overview.
These routes had no request-level tests, so a query rewrite that produced
invalid SQL or a broken scope would only have surfaced in production — and in
MT that means in one tenant's production. Each test drives the real route
through the test client for a role that exercises a DIFFERENT branch of the
scoping code:
admin — unscoped branch
inspector — InspectorAssignment / get_inspector_scope() branch
customer — CustomerAssignment / get_customer_scope() branch
The assertions are deliberately about behaviour that must hold (status code,
scope isolation), not about markup.
What specifically is being guarded
----------------------------------
* `with_entities(*_ISSUE_CARD_COLS)` on the dashboard cards — a Row must keep
answering the same attribute names the tallies and sla_status() read.
* `facility_filter.isdigit()` on the issues list — `int('abc')` used to raise
ValueError and return 500 on a hand-edited query string.
* `scalar_subquery()` in reports `_scope_issue()` — including the empty case,
where `IN (empty subquery)` must match nothing rather than error.
* The collapsed scope gate in `issues.view()` (`_issue_readable_by`), which
the linked-issues panel now shares — see rule 110.
"""
from datetime import timedelta
import pytest
@pytest.fixture
def client(app):
with app.app_context():
from app import db
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
# ── Builders ─────────────────────────────────────────────────────────────────
_n = {'i': 0}
def _uniq():
_n['i'] += 1
return _n['i']
def _user(role='admin'):
from app import db
from app.models.user import User
n = _uniq()
u = User(username=f'{role}{n}', full_name=f'{role.title()} {n}',
email=f'{role}{n}@example.com', role=role,
active=True, password_set=True)
u.set_password('pw-correct1')
db.session.add(u)
db.session.commit()
return u
def _project(name=None):
from app import db
from app.models.project import Project
p = Project(name=name or f'Contract {_uniq()}', active=True)
db.session.add(p)
db.session.commit()
return p
def _facility(project=None, name=None):
from app import db
from app.models.facility import Facility
f = Facility(name=name or f'Facility {_uniq()}', active=True,
project_id=project.id if project else None)
db.session.add(f)
db.session.commit()
return f
def _area(facility):
from app import db
from app.models.facility import Area
a = Area(facility_id=facility.id, name=f'Area {_uniq()}',
area_type='restroom')
db.session.add(a)
db.session.commit()
return a
def _issue(facility=None, area=None, severity='high', status='open',
hours_ago=1, **kw):
from app import db
from app.models.issue import Issue
from app.utils.time_utils import now_eastern
issue = Issue(
facility_id=facility.id if facility else None,
area_id=area.id if area else None,
severity=severity,
description=kw.pop('description', 'test issue'),
status=status,
reported_at=now_eastern() - timedelta(hours=hours_ago),
)
for k, v in kw.items():
setattr(issue, k, v)
db.session.add(issue)
db.session.commit()
return issue
def _template():
from app import db
from app.models.inspection import InspectionTemplate
t = InspectionTemplate(name=f'Template {_uniq()}', active=True,
form_schema=[])
db.session.add(t)
db.session.commit()
return t
def _inspection(facility, inspector, template, status='completed',
overall_score=90.0, days_ago=5, **kw):
from app import db
from app.models.inspection import Inspection
from app.utils.time_utils import now_eastern
insp = Inspection(
template_id=template.id,
facility_id=facility.id,
inspector_id=inspector.id,
inspection_date=now_eastern() - timedelta(days=days_ago),
overall_score=overall_score,
status=status,
)
for k, v in kw.items():
setattr(insp, k, v)
db.session.add(insp)
db.session.commit()
return insp
def _assign_inspector(user, project):
from app import db
from app.models.inspector_assignment import InspectorAssignment
db.session.add(InspectorAssignment(user_id=user.id, project_id=project.id))
db.session.commit()
def _assign_customer(user, project):
from app import db
from app.models.project import CustomerAssignment
db.session.add(CustomerAssignment(user_id=user.id, project_id=project.id))
db.session.commit()
def _login(client, user):
return client.post('/auth/login',
data={'username': user.username, 'password': 'pw-correct1'},
follow_redirects=False)
# ── Dashboard ────────────────────────────────────────────────────────────────
@pytest.mark.parametrize('role', ['admin', 'director', 'project_manager', 'auditor'])
def test_dashboard_renders_for_staff_roles(client, role):
"""The card queries (severity / handler / SLA splits) build and run."""
from app.utils.time_utils import now_eastern
facility = _facility(_project())
_issue(facility=facility, severity='critical', hours_ago=100)
_issue(facility=facility, severity='low', status='in_progress')
_issue(facility=facility, status='resolved', resolved_at=now_eastern())
_login(client, _user(role))
assert client.get('/').status_code == 200
def test_dashboard_renders_for_scoped_inspector(client):
project = _project()
inspector = _user('inspector')
_assign_inspector(inspector, project)
facility = _facility(project)
_issue(area=_area(facility), severity='high', hours_ago=50) # via area
_issue(facility=facility, severity='medium') # directly
_login(client, inspector)
assert client.get('/').status_code == 200
def test_dashboard_renders_for_inspector_with_no_assignments(client):
"""Strict scoping: no assignments must render an empty dashboard, not 500."""
_login(client, _user('inspector'))
assert client.get('/').status_code == 200
def test_dashboard_renders_for_customer(client):
project = _project()
customer = _user('customer')
_assign_customer(customer, project)
_issue(facility=_facility(project))
_login(client, customer)
assert client.get('/').status_code == 200
# ── Issues list ──────────────────────────────────────────────────────────────
def test_issues_list_renders(client):
_issue(facility=_facility(_project()))
_login(client, _user('admin'))
assert client.get('/issues/').status_code == 200
@pytest.mark.parametrize('query', [
'?facility_id=abc', # non-numeric — used to raise ValueError -> 500
'?facility_id=',
'?facility_id=999999', # numeric but nonexistent
'?contract_id=abc',
'?issue_id=abc',
'?severity=high&status=open',
'?handler_type=vendor',
'?unassigned=1',
'?sla=breached',
'?date_from=notadate&date_to=alsonot',
])
def test_issues_list_survives_malformed_filters(client, query):
"""A hand-edited or stale query string must never 500 the list."""
_issue(facility=_facility(_project()))
_login(client, _user('admin'))
assert client.get('/issues/' + query).status_code == 200
def test_issues_list_scopes_a_customer_to_their_own_facilities(client):
"""The scope filter still isolates customers after the query rewrite."""
mine_project, other_project = _project('Contract A'), _project('Contract B')
customer = _user('customer')
_assign_customer(customer, mine_project)
_issue(facility=_facility(mine_project, 'Mine'), description='visible to me')
_issue(facility=_facility(other_project, 'Theirs'),
description='other customer only')
_login(client, customer)
body = client.get('/issues/').get_data(as_text=True)
assert 'visible to me' in body
assert 'other customer only' not in body
# ── Issue detail scope gate ──────────────────────────────────────────────────
# issues.view() had two inline scope blocks; they were collapsed into
# _issue_readable_by() so the linked-issues panel could reuse the same rule
# (rule 110). These pin the behaviour that gate must keep.
def test_issue_view_allows_an_inspector_inside_their_contract(client):
project = _project()
inspector = _user('inspector')
_assign_inspector(inspector, project)
issue = _issue(facility=_facility(project))
_login(client, inspector)
assert client.get(f'/issues/{issue.id}').status_code == 200
def test_issue_view_denies_an_inspector_outside_their_contract(client):
mine, theirs = _project(), _project()
inspector = _user('inspector')
_assign_inspector(inspector, mine)
issue = _issue(facility=_facility(theirs), description='not yours')
_login(client, inspector)
res = client.get(f'/issues/{issue.id}', follow_redirects=True)
assert 'not yours' not in res.get_data(as_text=True)
def test_issue_view_denies_an_inspector_with_no_assignments(client):
"""Strict scoping: no assignments means no access, not full access."""
issue = _issue(facility=_facility(_project()), description='strictly scoped')
_login(client, _user('inspector'))
res = client.get(f'/issues/{issue.id}', follow_redirects=True)
assert 'strictly scoped' not in res.get_data(as_text=True)
def test_issue_view_denies_a_customer_outside_their_contract(client):
mine, theirs = _project(), _project()
customer = _user('customer')
_assign_customer(customer, mine)
issue = _issue(facility=_facility(theirs), description='other customer only')
_login(client, customer)
res = client.get(f'/issues/{issue.id}', follow_redirects=True)
assert 'other customer only' not in res.get_data(as_text=True)
def test_issue_view_allows_a_customer_inside_their_contract(client):
project = _project()
customer = _user('customer')
_assign_customer(customer, project)
issue = _issue(facility=_facility(project), description='mine to read')
_login(client, customer)
res = client.get(f'/issues/{issue.id}')
assert res.status_code == 200
assert 'mine to read' in res.get_data(as_text=True)
# ── Reports overview ─────────────────────────────────────────────────────────
def test_reports_overview_renders_for_admin(client):
project = _project()
_inspection(_facility(project), _user('inspector'), _template())
_login(client, _user('admin'))
assert client.get('/reports/').status_code == 200
def test_reports_overview_inspector_scope_uses_subquery(client):
"""_scope_issue() now filters via a subquery instead of an id list."""
project = _project()
inspector = _user('inspector')
_assign_inspector(inspector, project)
facility = _facility(project)
insp = _inspection(facility, inspector, _template())
_issue(facility=facility, inspection_id=insp.id)
_login(client, inspector)
assert client.get('/reports/').status_code == 200
def test_reports_overview_inspector_with_no_inspections(client):
"""IN (empty subquery) must match nothing rather than error."""
_login(client, _user('inspector'))
assert client.get('/reports/').status_code == 200
+199
View File
@@ -0,0 +1,199 @@
"""
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