Sep 4 - Add link relavant issues function
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user