Sep 4 - Add link relavant issues function

This commit is contained in:
2026-09-04 13:17:33 -04:00
parent b7bc0f0335
commit 50df63115e
7 changed files with 1320 additions and 15 deletions
+485
View File
@@ -0,0 +1,485 @@
"""
Tests for issue linking (duplicate / related).
Two things are worth testing here. The first is the link semantics: one stored
row read from both sides, no self-links, no double-linking in either direction.
The second, and the reason this file is long, is SCOPE. A link is a pointer to
another issue's id, description and facility, so it is a potential way to read
an issue you have no access to. Three surfaces have to hold that line
independently -- the panel that renders links, the search that finds candidates,
and the POST that creates them -- and only the POST is a real boundary.
"""
import pytest
from app import db as _db
from app.models.issue import Issue, IssueLink
from app.models.project import Project, CustomerAssignment
from app.models.inspector_assignment import InspectorAssignment
from app.utils.time_utils import now_eastern
# ── Fixtures ─────────────────────────────────────────────────────────────────
@pytest.fixture
def make_issue(db):
def _make(facility=None, area=None, severity='high', status='open', **kw):
issue = Issue(
facility_id=facility.id if facility else None,
area_id=area.id if area else None,
severity=severity,
description=kw.pop('description', 'a leak under the sink'),
status=status,
reported_at=now_eastern(),
)
for k, v in kw.items():
setattr(issue, k, v)
db.session.add(issue)
db.session.commit()
return issue
return _make
@pytest.fixture
def make_project(db):
def _make(name='Contract A'):
proj = Project(name=name, active=True)
db.session.add(proj)
db.session.commit()
return proj
return _make
@pytest.fixture
def link_url():
def _make(issue):
return f'/issues/{issue.id}/links'
return _make
# ── Model semantics ──────────────────────────────────────────────────────────
def test_a_link_reads_differently_from_each_end(app, db, make_issue, make_facility):
"""One stored row; 'duplicate' is directional and says so on both issues."""
facility = make_facility()
dupe = make_issue(facility=facility)
orig = make_issue(facility=facility)
db.session.add(IssueLink(issue_id=dupe.id, linked_issue_id=orig.id,
link_type='duplicate'))
db.session.commit()
link = IssueLink.query.one()
assert link.label_for(dupe.id) == 'Duplicate of'
assert link.label_for(orig.id) == 'Duplicated by'
assert link.other_issue(dupe.id).id == orig.id
assert link.other_issue(orig.id).id == dupe.id
def test_related_reads_the_same_from_both_ends(app, db, make_issue, make_facility):
facility = make_facility()
a, b = make_issue(facility=facility), make_issue(facility=facility)
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
link_type='related'))
db.session.commit()
link = IssueLink.query.one()
assert link.label_for(a.id) == 'Related to'
assert link.label_for(b.id) == 'Related to'
def test_one_row_appears_on_both_issues(app, db, make_issue, make_facility):
"""all_links() merges the two storage directions into one list."""
facility = make_facility()
a, b = make_issue(facility=facility), make_issue(facility=facility)
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
link_type='related'))
db.session.commit()
assert len(a.all_links()) == 1
assert len(b.all_links()) == 1
assert IssueLink.query.count() == 1
def test_exists_between_is_direction_agnostic(app, db, make_issue, make_facility):
"""The stored UniqueConstraint only covers one direction -- this covers both."""
facility = make_facility()
a, b = make_issue(facility=facility), make_issue(facility=facility)
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
link_type='related'))
db.session.commit()
assert IssueLink.exists_between(a.id, b.id)
assert IssueLink.exists_between(b.id, a.id)
assert not IssueLink.exists_between(a.id, 99999)
def test_deleting_an_issue_removes_its_links_from_both_sides(
app, db, make_issue, make_facility):
"""A surviving link would render a dead row on the other issue's page."""
facility = make_facility()
a, b, c = (make_issue(facility=facility) for _ in range(3))
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id, link_type='related'))
db.session.add(IssueLink(issue_id=c.id, linked_issue_id=a.id, link_type='duplicate'))
db.session.commit()
assert IssueLink.query.count() == 2
db.session.delete(a) # the path both the single and bulk delete use
db.session.commit()
assert IssueLink.query.count() == 0
assert b.all_links() == []
assert c.all_links() == []
# ── Creating links through the route ─────────────────────────────────────────
def test_admin_can_link_two_issues(client, login, make_user, make_facility,
make_issue, link_url):
facility = make_facility()
a, b = make_issue(facility=facility), make_issue(facility=facility)
login(make_user(role='admin'))
res = client.post(link_url(a),
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
follow_redirects=True)
assert res.status_code == 200
link = IssueLink.query.one()
assert (link.issue_id, link.linked_issue_id) == (a.id, b.id)
assert link.link_type == 'duplicate'
def test_a_leading_hash_is_accepted(client, login, make_user, make_facility,
make_issue, link_url):
"""People type '#412' because that is how the id is shown everywhere."""
facility = make_facility()
a, b = make_issue(facility=facility), make_issue(facility=facility)
login(make_user(role='admin'))
client.post(link_url(a),
data={'link_type': 'related', 'linked_issue_id': f'#{b.id}'},
follow_redirects=True)
assert IssueLink.query.count() == 1
def test_linking_does_not_touch_either_issue(client, login, make_user,
make_facility, make_issue, link_url):
"""The chosen design: a link is navigation, not a workflow action."""
facility = make_facility()
a = make_issue(facility=facility, status='open')
b = make_issue(facility=facility, status='open')
login(make_user(role='admin'))
client.post(link_url(a),
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
follow_redirects=True)
_db.session.refresh(a)
_db.session.refresh(b)
assert a.status == 'open' and a.resolved_at is None and a.assigned_to is None
assert b.status == 'open' and b.resolved_at is None
@pytest.mark.parametrize('payload', [
{'link_type': 'related', 'linked_issue_id': 'abc'},
{'link_type': 'related', 'linked_issue_id': ''},
{'link_type': 'nonsense', 'linked_issue_id': '1'},
{'link_type': '', 'linked_issue_id': '1'},
{},
])
def test_malformed_link_requests_are_rejected_without_error(
client, login, make_user, make_facility, make_issue, link_url, payload):
facility = make_facility()
a = make_issue(facility=facility)
make_issue(facility=facility)
login(make_user(role='admin'))
res = client.post(link_url(a), data=payload, follow_redirects=True)
assert res.status_code == 200
assert IssueLink.query.count() == 0
def test_an_issue_cannot_be_linked_to_itself(client, login, make_user,
make_facility, make_issue, link_url):
facility = make_facility()
a = make_issue(facility=facility)
login(make_user(role='admin'))
client.post(link_url(a),
data={'link_type': 'related', 'linked_issue_id': str(a.id)},
follow_redirects=True)
assert IssueLink.query.count() == 0
def test_the_same_pair_cannot_be_linked_twice_in_either_direction(
client, login, make_user, make_facility, make_issue, link_url):
facility = make_facility()
a, b = make_issue(facility=facility), make_issue(facility=facility)
login(make_user(role='admin'))
client.post(link_url(a), data={'link_type': 'related',
'linked_issue_id': str(b.id)},
follow_redirects=True)
client.post(link_url(a), data={'link_type': 'duplicate',
'linked_issue_id': str(b.id)},
follow_redirects=True)
client.post(link_url(b), data={'link_type': 'duplicate',
'linked_issue_id': str(a.id)},
follow_redirects=True)
assert IssueLink.query.count() == 1
def test_unlinking_removes_the_row_and_works_from_either_end(
client, login, make_user, make_facility, make_issue, db):
facility = make_facility()
a, b = make_issue(facility=facility), make_issue(facility=facility)
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
link_type='related'))
db.session.commit()
link_id = IssueLink.query.one().id
login(make_user(role='admin'))
# from the far end, which is the row's linked_issue_id
client.post(f'/issues/{b.id}/links/{link_id}/delete', follow_redirects=True)
assert IssueLink.query.count() == 0
def test_cannot_delete_a_link_between_two_other_issues(
client, login, make_user, make_facility, make_issue, db):
"""The link id is posted by the client, so it must be checked against
the issue in the URL -- otherwise any link is deletable from anywhere."""
facility = make_facility()
a, b, unrelated = (make_issue(facility=facility) for _ in range(3))
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
link_type='related'))
db.session.commit()
link_id = IssueLink.query.one().id
login(make_user(role='admin'))
client.post(f'/issues/{unrelated.id}/links/{link_id}/delete',
follow_redirects=True)
assert IssueLink.query.count() == 1
# ── Permission ───────────────────────────────────────────────────────────────
@pytest.mark.parametrize('role', ['admin', 'director', 'auditor'])
def test_issue_managers_may_link(client, login, make_user, make_facility,
make_issue, link_url, role):
facility = make_facility()
a, b = make_issue(facility=facility), make_issue(facility=facility)
login(make_user(role=role))
client.post(link_url(a), data={'link_type': 'related',
'linked_issue_id': str(b.id)},
follow_redirects=True)
assert IssueLink.query.count() == 1
def test_the_assignee_may_link_their_own_issue(client, login, make_user, db,
make_facility, make_issue,
make_project, link_url):
project = make_project()
facility = make_facility(project=project)
inspector = make_user(role='inspector')
db.session.add(InspectorAssignment(user_id=inspector.id, project_id=project.id))
db.session.commit()
a = make_issue(facility=facility, assigned_to=inspector.id)
b = make_issue(facility=facility)
login(inspector)
client.post(link_url(a), data={'link_type': 'related',
'linked_issue_id': str(b.id)},
follow_redirects=True)
assert IssueLink.query.count() == 1
def test_a_project_manager_may_not_link(client, login, make_user,
make_facility, make_issue, link_url):
"""Matches the page's existing can_edit set. Widening this is a decision,
not an accident -- if it changes, issues/view.html must change too."""
facility = make_facility()
a, b = make_issue(facility=facility), make_issue(facility=facility)
login(make_user(role='project_manager'))
res = client.post(link_url(a), data={'link_type': 'related',
'linked_issue_id': str(b.id)})
assert res.status_code == 403
assert IssueLink.query.count() == 0
def test_a_customer_may_not_link_even_on_their_own_facility(
client, login, make_user, db, make_facility, make_issue,
make_project, link_url):
project = make_project()
facility = make_facility(project=project)
customer = make_user(role='customer')
db.session.add(CustomerAssignment(user_id=customer.id, project_id=project.id))
db.session.commit()
a, b = make_issue(facility=facility), make_issue(facility=facility)
login(customer)
res = client.post(link_url(a), data={'link_type': 'related',
'linked_issue_id': str(b.id)})
assert res.status_code == 403
assert IssueLink.query.count() == 0
# ── Scope: the part that actually matters ────────────────────────────────────
def test_cannot_link_to_an_issue_outside_your_scope(
client, login, make_user, db, make_facility, make_issue,
make_project, link_url):
"""An inspector must not be able to attach another contract's issue."""
mine = make_project('Mine')
theirs = make_project('Theirs')
my_facility = make_facility(project=mine)
their_facility = make_facility(project=theirs)
inspector = make_user(role='inspector')
db.session.add(InspectorAssignment(user_id=inspector.id, project_id=mine.id))
db.session.commit()
a = make_issue(facility=my_facility, assigned_to=inspector.id)
out_of_scope = make_issue(facility=their_facility,
description='another contract')
login(inspector)
res = client.post(link_url(a),
data={'link_type': 'related',
'linked_issue_id': str(out_of_scope.id)},
follow_redirects=True)
assert IssueLink.query.count() == 0
# And the refusal must not confirm the issue exists.
body = res.get_data(as_text=True)
assert 'another contract' not in body
def test_the_panel_hides_a_link_whose_far_end_is_out_of_scope(
client, login, make_user, db, make_facility, make_issue, make_project):
"""An admin can link across contracts. A customer at one end must still not
read the issue at the other."""
mine = make_project('Mine')
theirs = make_project('Theirs')
my_facility = make_facility(project=mine)
their_facility = make_facility(project=theirs)
customer = make_user(role='customer')
db.session.add(CustomerAssignment(user_id=customer.id, project_id=mine.id))
db.session.commit()
visible = make_issue(facility=my_facility, description='my own issue')
hidden = make_issue(facility=their_facility,
description='SECRET other customer issue')
db.session.add(IssueLink(issue_id=visible.id, linked_issue_id=hidden.id,
link_type='related'))
db.session.commit()
login(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, login, make_user, db, make_facility,
make_issue, make_project):
mine = make_project('Mine')
theirs = make_project('Theirs')
my_facility = make_facility(project=mine)
their_facility = make_facility(project=theirs)
inspector = make_user(role='inspector')
db.session.add(InspectorAssignment(user_id=inspector.id, project_id=mine.id))
db.session.commit()
a = make_issue(facility=my_facility, description='mine mine mine')
findable = make_issue(facility=my_facility, description='findable mine')
hidden = make_issue(facility=their_facility, description='findable theirs')
login(inspector)
data = client.get(f'/issues/{a.id}/link-search?q=findable').get_json()
ids = {r['id'] for r in data['results']}
assert findable.id in ids
assert hidden.id not in ids
def test_link_search_excludes_self_and_already_linked(
client, login, make_user, db, make_facility, make_issue):
facility = make_facility()
a = make_issue(facility=facility, description='widget one')
already = make_issue(facility=facility, description='widget two')
free = make_issue(facility=facility, description='widget three')
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=already.id,
link_type='related'))
db.session.commit()
login(make_user(role='admin'))
data = client.get(f'/issues/{a.id}/link-search?q=widget').get_json()
ids = {r['id'] for r in data['results']}
assert ids == {free.id}
def test_link_search_finds_by_issue_number(client, login, make_user,
make_facility, make_issue):
facility = make_facility()
a = make_issue(facility=facility)
target = make_issue(facility=facility, description='nothing in common')
login(make_user(role='admin'))
data = client.get(f'/issues/{a.id}/link-search?q={target.id}').get_json()
assert target.id in {r['id'] for r in data['results']}
def test_link_search_returns_nothing_for_an_unscoped_inspector(
client, login, make_user, make_facility, make_issue):
"""Strict scoping: no assignments means no candidates, not all of them."""
facility = make_facility()
a = make_issue(facility=facility, description='findable')
make_issue(facility=facility, description='findable too')
inspector = make_user(role='inspector')
login(inspector)
# No InspectorAssignment -> cannot even open the issue.
assert client.get(f'/issues/{a.id}/link-search?q=findable').status_code == 403
# ── Rendering ────────────────────────────────────────────────────────────────
def test_the_panel_renders_both_directions(client, login, make_user, db,
make_facility, make_issue):
facility = make_facility()
subject = make_issue(facility=facility, description='the one being viewed')
original = make_issue(facility=facility, description='the original')
other = make_issue(facility=facility, description='a related thing')
db.session.add(IssueLink(issue_id=subject.id, linked_issue_id=original.id,
link_type='duplicate'))
db.session.add(IssueLink(issue_id=other.id, linked_issue_id=subject.id,
link_type='duplicate'))
db.session.commit()
login(make_user(role='admin'))
body = client.get(f'/issues/{subject.id}').get_data(as_text=True)
assert 'Duplicate of' in body # subject -> original
assert 'Duplicated by' in body # other -> subject
assert f'/issues/{original.id}' in body
assert f'/issues/{other.id}' in body
def test_the_panel_renders_with_no_links(client, login, make_user,
make_facility, make_issue):
facility = make_facility()
issue = make_issue(facility=facility)
login(make_user(role='admin'))
body = client.get(f'/issues/{issue.id}').get_data(as_text=True)
assert 'Linked Issues' in body
assert 'No linked issues' in body