import pytest from django.core.exceptions import PermissionDenied, ValidationError from django.urls import reverse from intent.comments import services from intent.comments.models import Comment, CommentThread, ThreadKind from intent.features.services import create_feature, update_feature from intent.issues.models import IssueStatus from intent.issues.services import create_issue, promote_thread, set_status DESCRIPTION = "Intro line\n\nSecond para\nwraps here.\n" def furl(name, feature, **kwargs): kwargs = { "org_slug": "catbriar", "project_slug": "intent", "feature_key": feature.key, **kwargs, } return reverse(f"features:{name}", kwargs=kwargs) def curl(name, **kwargs): return reverse( f"comments:{name}", kwargs={"org_slug": "catbriar", "project_slug": "intent", **kwargs} ) @pytest.fixture def feature(open_project, owner): return create_feature(open_project, owner, title="Sign in", description=DESCRIPTION) @pytest.fixture def thread(feature, outsider): return services.start_feature_thread(feature, outsider, body="Why two paragraphs?") def test_line_thread_must_match_current_revision_and_lines(feature, outsider, owner): t = services.start_feature_thread( feature, outsider, body="Hm", revision=1, line_start=3, line_end=4 ) assert t.kind == ThreadKind.LINE with pytest.raises(ValidationError, match="changed"): services.start_feature_thread( feature, outsider, body="x", revision=2, line_start=1, line_end=1 ) with pytest.raises(ValidationError): services.start_feature_thread( feature, outsider, body="x", revision=1, line_start=3, line_end=9 ) with pytest.raises(ValidationError): services.start_feature_thread(feature, outsider, body=" ") def test_private_project_outsiders_cannot_comment(project, owner, make_user): feature = create_feature(project, owner, title="Secret") with pytest.raises(PermissionDenied): services.start_feature_thread(feature, make_user(), body="hi") def test_edit_keeps_history_and_only_author_edits(thread, outsider, owner): comment = thread.comments.get() with pytest.raises(PermissionDenied): services.edit_comment(comment, owner, body="Rewritten") services.edit_comment(comment, outsider, body="Why two?") comment.refresh_from_db() assert comment.edited_at and comment.edits.get().body == "Why two paragraphs?" with pytest.raises(ValidationError): services.edit_comment(comment, outsider, body="Why two?") def test_delete_leaves_tombstone_and_drops_history(thread, outsider, make_user, owner, editor): comment = thread.comments.get() services.edit_comment(comment, outsider, body="Edited") with pytest.raises(PermissionDenied): services.delete_comment(comment, make_user()) with pytest.raises(PermissionDenied): services.delete_comment(comment, editor) # Collaborators don't moderate; owners do. services.delete_comment(comment, owner) comment.refresh_from_db() assert comment.is_deleted and comment.body == "" and not comment.edits.exists() assert comment.deleted_by == owner def test_resolve_by_thread_author_or_team(thread, outsider, editor, make_user): with pytest.raises(PermissionDenied): services.set_resolved(thread, make_user(), True) services.set_resolved(thread, outsider, True) assert thread.is_resolved services.set_resolved(thread, editor, False) assert not thread.is_resolved def test_issue_threads_cannot_be_resolved(open_project, outsider, owner): issue = create_issue(open_project, outsider, title="Bug") with pytest.raises(PermissionDenied): services.set_resolved(issue.threads.get(), owner, True) def test_promote_by_author_or_team_only(thread, feature, outsider, make_user, editor): services.reply(thread, editor, body="Good question") with pytest.raises(PermissionDenied): promote_thread(thread, make_user(), title="X", body="") issue = promote_thread(thread, outsider, title="Two paragraphs", body="Quoted") thread.refresh_from_db() assert thread.promoted_to == issue and thread.is_resolved assert list(issue.features.all()) == [feature] assert issue.author == outsider with pytest.raises(PermissionDenied): promote_thread(thread, editor, title="Again", body="") def test_promotion_text_quotes_lines_and_comments(feature, outsider, editor): from intent.issues.services import promotion_text t = services.start_feature_thread( feature, outsider, body="Unclear wording", revision=1, line_start=3, line_end=4 ) services.reply(t, editor, body="Agreed") title, body = promotion_text(t) assert title == "Unclear wording" assert "lines 3–4 of revision 1" in body assert "> Second para\n> wraps here." in body assert "**eddie** wrote:\n\n> Agreed" in body # Views def test_feature_page_shows_line_threads_under_their_block(client, feature, outsider): services.start_feature_thread( feature, outsider, body="About para two", revision=1, line_start=3, line_end=4 ) client.force_login(outsider) page = client.get(furl("detail", feature)).content.decode() block = page.index('id="L3-4"') assert block < page.index("About para two") < page.index('id="discussion"') assert 'id="body-L1-1"' in page # A comment form for each block. def test_line_threads_go_outdated_only_when_text_changes(client, feature, outsider, owner): services.start_feature_thread( feature, outsider, body="About para two", revision=1, line_start=3, line_end=4 ) update_feature(feature, owner, note="Withdraw", status="withdrawn") # Text unchanged. client.force_login(outsider) page = client.get(furl("detail", feature)).content.decode() assert "Comments on earlier revisions" not in page update_feature(feature, owner, note="Reword", description="Intro\n\nNew text\n") page = client.get(furl("detail", feature)).content.decode() outdated = page.index("Comments on earlier revisions") assert outdated < page.index("About para two") assert "Second para\nwraps here." in page # The original lines are quoted. assert furl("revision", feature, revision=1) + "#L3-4" in page def test_create_thread_via_form(client, feature, outsider): client.force_login(outsider) response = client.post(furl("thread_create", feature), {"body": "General point"}) thread = CommentThread.objects.get() assert response["Location"] == furl("detail", feature) + f"#thread-{thread.pk}" assert thread.kind == ThreadKind.FEATURE def test_stale_line_comment_shows_the_draft(client, feature, outsider, owner): update_feature(feature, owner, note="Edit", description="Changed\n") client.force_login(outsider) response = client.post( furl("thread_create", feature), {"body": "My long thought", "revision": 1, "line_start": 1, "line_end": 1}, ) assert response.status_code == 400 assert b"My long thought" in response.content and b"changed" in response.content assert not CommentThread.objects.exists() def test_reply_without_js_redirects_to_the_comment(client, thread, outsider): client.force_login(outsider) response = client.post(curl("reply", thread_id=thread.pk), {"body": "More"}) comment = Comment.objects.get(body="More") assert response["Location"].endswith(f"#comment-{comment.pk}") def test_reply_with_htmx_returns_the_thread_fragment(client, thread, outsider): client.force_login(outsider) response = client.post( curl("reply", thread_id=thread.pk), {"body": "More"}, headers={"HX-Request": "true"} ) html = response.content.decode() assert response.status_code == 200 assert html.lstrip().startswith(f'