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