intent/tests/test_issues.py
Alec Perkins 5811e72a0c Add issues and comment threads (Phase 3)
Issues: per-project numbers, statuses (open, accepted, rejected,
withdrawn, duplicate), feature links, edits, and a timeline of comments
and events. From an issue, collaborators create or update a feature
with the revision tied to the issue and an option to accept it.

Comments: general and line-anchored threads on features, one
discussion thread per issue, edit history, tombstone deletes, resolve,
and promoting a feature thread to an issue. Line threads attach to
top-level Markdown blocks and go outdated only when the text changes.

HTMX swaps threads in place with focus management; every action also
works as a plain form post. Markdown headings are re-levelled so user
text never skips heading levels on the page.

The example flow from concept.md (steps 1-7) is covered end to end in
tests/test_example_flow.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:18:45 -04:00

252 lines
10 KiB
Python

import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import IntegrityError
from django.urls import reverse
from intent.comments.models import CommentThread, ThreadKind
from intent.features.services import create_feature, update_feature
from intent.issues import services
from intent.issues.models import EventKind, Issue, IssueStatus
def iurl(name, project, **kwargs):
kwargs = {"org_slug": project.org.slug, "project_slug": project.slug, **kwargs}
return reverse(f"issues:{name}", kwargs=kwargs)
@pytest.fixture
def issue(open_project, outsider):
return services.create_issue(open_project, outsider, title="Sign in is slow", body="Why?")
def test_numbers_are_per_project_and_issue_gets_a_thread(open_project, outsider, issue):
second = services.create_issue(open_project, outsider, title="Another")
assert (issue.key, second.key) == ("#1", "#2")
assert CommentThread.objects.get(issue=issue).kind == ThreadKind.ISSUE
def test_outsiders_need_open_contribution(project, outsider):
with pytest.raises(PermissionDenied):
services.create_issue(project, outsider, title="Nope")
project.visibility = "public"
project.save()
with pytest.raises(PermissionDenied):
services.create_issue(project, outsider, title="Still no")
def test_title_required(open_project, outsider):
with pytest.raises(ValidationError):
services.create_issue(open_project, outsider, title=" ")
def test_only_team_decides(issue, outsider, editor):
with pytest.raises(PermissionDenied):
services.set_status(issue, outsider, IssueStatus.ACCEPTED)
services.set_status(issue, editor, IssueStatus.REJECTED)
issue.refresh_from_db()
assert issue.status == IssueStatus.REJECTED and issue.closed_at
services.set_status(issue, editor, IssueStatus.OPEN)
issue.refresh_from_db()
assert issue.is_open and issue.closed_at is None
assert [e.status for e in issue.events.all()] == ["rejected", "open"]
def test_author_withdraws_and_reopens_own_issue(issue, outsider, make_user):
with pytest.raises(PermissionDenied):
services.set_status(issue, make_user(), IssueStatus.WITHDRAWN)
services.set_status(issue, outsider, IssueStatus.WITHDRAWN)
services.set_status(issue, outsider, IssueStatus.OPEN)
services.set_status(issue, outsider, IssueStatus.WITHDRAWN)
issue.refresh_from_db()
assert issue.status == IssueStatus.WITHDRAWN
def test_author_cannot_reopen_a_rejected_issue(issue, outsider, editor):
services.set_status(issue, editor, IssueStatus.REJECTED)
with pytest.raises(PermissionDenied):
services.set_status(issue, outsider, IssueStatus.OPEN)
def test_same_status_is_refused(issue, editor):
with pytest.raises(ValidationError):
services.set_status(issue, editor, IssueStatus.OPEN)
def test_duplicates_need_an_original_and_no_cycles(open_project, outsider, editor, issue):
other = services.create_issue(open_project, outsider, title="Slow sign in")
with pytest.raises(ValidationError):
services.set_status(other, editor, IssueStatus.DUPLICATE)
with pytest.raises(ValidationError):
services.set_status(other, editor, IssueStatus.DUPLICATE, duplicate_of=other)
services.set_status(other, editor, IssueStatus.DUPLICATE, duplicate_of=issue)
with pytest.raises(ValidationError):
services.set_status(issue, editor, IssueStatus.DUPLICATE, duplicate_of=other)
# Reopening clears the link.
services.set_status(other, editor, IssueStatus.OPEN)
other.refresh_from_db()
assert other.duplicate_of is None
def test_database_enforces_duplicate_of(issue):
with pytest.raises(IntegrityError):
Issue.objects.filter(pk=issue.pk).update(status=IssueStatus.DUPLICATE)
def test_edit_keeps_old_text_in_timeline(issue, outsider, make_user):
with pytest.raises(PermissionDenied):
services.update_issue(issue, make_user(), title="Hijack", body="")
services.update_issue(issue, outsider, title="Sign in is very slow", body="Why?")
event = issue.events.get(kind=EventKind.EDITED)
assert event.old_title == "Sign in is slow"
with pytest.raises(ValidationError):
services.update_issue(issue, outsider, title="Sign in is very slow", body="Why?")
def test_link_and_unlink_features(issue, editor, outsider, open_project, owner):
feature = create_feature(open_project, owner, title="Sign in")
with pytest.raises(PermissionDenied):
services.link_feature(issue, outsider, feature)
services.link_feature(issue, editor, feature)
assert list(feature.issues.all()) == [issue]
with pytest.raises(ValidationError):
services.link_feature(issue, editor, feature)
services.unlink_feature(issue, editor, feature)
assert not feature.issues.exists()
def test_feature_revision_can_accept_an_issue(issue, editor, open_project, owner):
feature = create_feature(open_project, owner, title="Sign in")
revision = update_feature(
feature, editor, note="Cache sessions", title="Fast sign in", issues=[issue],
accept_issues=True,
) # fmt: skip
issue.refresh_from_db()
assert issue.status == IssueStatus.ACCEPTED
assert list(revision.issues.all()) == [issue]
assert list(issue.features.all()) == [feature]
assert issue.events.filter(kind=EventKind.REVISION, revision=revision).exists()
def test_cross_project_issue_cannot_be_tied_to_a_feature(issue, org, owner):
from intent.projects.services import create_project
other = create_project(
org, owner, slug="other", name="Other", feature_prefix="OTH", visibility="private",
open_contribution=False,
) # fmt: skip
with pytest.raises(ValidationError):
create_feature(other, owner, title="X", issues=[issue])
# Views
def test_issue_pages_for_outsider_on_open_project(client, open_project, outsider, issue):
client.force_login(outsider)
assert client.get(iurl("list", open_project)).status_code == 200
assert client.get(iurl("create", open_project)).status_code == 200
page = client.get(iurl("detail", open_project, number=1)).content.decode()
assert "Sign in is slow" in page
assert 'value="accepted"' not in page # No decision buttons for outsiders.
assert 'value="withdrawn"' in page
def test_private_issues_are_hidden(client, project, owner, outsider):
issue = services.create_issue(project, owner, title="Secret")
client.force_login(outsider)
assert client.get(iurl("detail", project, number=issue.number)).status_code == 404
def test_create_via_form_with_feature_prefill(client, open_project, outsider, owner):
feature = create_feature(open_project, owner, title="Sign in")
client.force_login(outsider)
page = client.get(iurl("create", open_project) + "?feature=INT-1").content.decode()
assert f'value="{feature.pk}" selected' in page
response = client.post(
iurl("create", open_project),
{"title": "Faster", "body": "Please", "features": [feature.pk]},
)
issue = Issue.objects.get(title="Faster")
assert response["Location"] == iurl("detail", open_project, number=issue.number)
assert list(issue.features.all()) == [feature]
def test_status_buttons(client, open_project, editor, issue):
client.force_login(editor)
client.post(iurl("status", open_project, number=1), {"status": "accepted"})
issue.refresh_from_db()
assert issue.status == IssueStatus.ACCEPTED
def test_duplicate_by_number(client, open_project, editor, outsider, issue):
other = services.create_issue(open_project, outsider, title="Again")
client.force_login(editor)
client.post(iurl("status", open_project, number=2), {"status": "duplicate", "number": 99})
other.refresh_from_db()
assert other.is_open
client.post(iurl("status", open_project, number=2), {"status": "duplicate", "number": 1})
other.refresh_from_db()
assert other.duplicate_of == issue
def test_outsider_cannot_post_status_or_links(client, open_project, outsider, issue, owner):
feature = create_feature(open_project, owner, title="Sign in")
client.force_login(outsider)
assert (
client.post(iurl("status", open_project, number=1), {"status": "accepted"}).status_code
== 403
)
assert (
client.post(
iurl("features", open_project, number=1), {"action": "link", "feature": feature.pk}
).status_code
== 403
)
def test_accept_and_create_feature_from_issue(client, open_project, editor, issue):
client.force_login(editor)
create = reverse("features:create", kwargs={"org_slug": "catbriar", "project_slug": "intent"})
page = client.get(f"{create}?issue=1").content.decode()
assert 'value="Sign in is slow"' in page and "Mark issue #1 accepted" in page
client.post(
f"{create}?issue=1",
{"title": "Fast sign in", "description": "Why?", "note": "", "accept_issue": "on"},
)
issue.refresh_from_db()
feature = issue.features.get()
assert feature.title == "Fast sign in"
assert issue.status == IssueStatus.ACCEPTED
assert feature.revisions.get().issues.get() == issue
def test_update_feature_for_issue_without_accepting(client, open_project, editor, issue, owner):
feature = create_feature(open_project, owner, title="Sign in")
client.force_login(editor)
edit = reverse(
"features:edit",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": "INT-1"},
)
page = client.get(f"{edit}?issue=1").content.decode()
assert "#1: Sign in is slow" in page
client.post(
f"{edit}?issue=1",
{"title": "Sign in", "description": "Faster.", "status": "active", "note": "Speed"},
)
issue.refresh_from_db()
assert issue.is_open and list(issue.features.all()) == [feature]
history = reverse(
"features:history",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": "INT-1"},
)
assert iurl("detail", open_project, number=1) in client.get(history).content.decode()
def test_list_filters(client, open_project, editor, outsider, issue):
services.create_issue(open_project, outsider, title="Closed one")
services.set_status(Issue.objects.get(number=2), editor, IssueStatus.REJECTED)
client.force_login(outsider)
open_page = client.get(iurl("list", open_project)).content.decode()
closed_page = client.get(iurl("list", open_project) + "?status=closed").content.decode()
assert "Sign in is slow" in open_page and "Closed one" not in open_page
assert "Closed one" in closed_page and "Sign in is slow" not in closed_page