253 lines
10 KiB
Python
253 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
|