Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/backend/src/couchers/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"incomplete_profile_create_discussion": "You have to complete your profile before you can start a discussion.",
"incomplete_profile_create_event": "You have to complete your profile before you can create an event.",
"incomplete_profile_create_public_trip": "You have to complete your profile before you can create a public trip.",
"incomplete_profile_post_comment": "You have to complete your profile before you can post a comment.",
"incomplete_profile_send_friend_request": "You have to complete your profile before you can send a friend request.",
"incomplete_profile_send_message": "You have to complete your profile before you can send a message.",
"incomplete_profile_send_request": "You have to complete your profile before you can send a request.",
Expand Down
5 changes: 5 additions & 0 deletions app/backend/src/couchers/servicers/threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from couchers.context import CouchersContext, make_background_user_context, make_notification_user_context
from couchers.db import session_scope
from couchers.helpers.completed_profile import has_completed_profile
from couchers.jobs.enqueue import queue_job
from couchers.models import (
Comment,
Expand Down Expand Up @@ -385,6 +386,10 @@ def PostReply(
if depth not in (0, 1):
context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found")

user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
if not has_completed_profile(session, user):
context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_post_comment")

object_to_add: Comment | Reply | None = None

def create_object(moderation_state_id: int) -> int:
Expand Down
103 changes: 100 additions & 3 deletions app/backend/src/tests/test_threads.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import string
import textwrap
from datetime import timedelta

import grpc
import pytest
Expand All @@ -17,11 +18,12 @@
User,
)
from couchers.models.discussions import CommentVersion, ContentChangeType, ReplyVersion
from couchers.proto import moderation_pb2, threads_pb2
from couchers.proto import discussions_pb2, events_pb2, moderation_pb2, threads_pb2
from couchers.servicers.threads import pack_thread_id
from couchers.utils import now
from couchers.utils import Timestamp_from_datetime, now
from tests.fixtures.db import generate_user
from tests.fixtures.sessions import real_moderation_session, threads_session
from tests.fixtures.sessions import discussions_session, events_session, real_moderation_session, threads_session
from tests.test_communities import create_community


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -539,6 +541,101 @@ def test_delete_reply_creates_version_record(db):
assert v.editor_user_id == user.id


def _create_event_and_get_thread_id(organizer, organizer_token: str) -> int:
"""Helper: create an Event via the API, return its (packed) thread_id."""
with session_scope() as session:
create_community(session, 0, 2, "Testing Community", [organizer], [], None)

start_time = now() + timedelta(hours=2)
end_time = start_time + timedelta(hours=3)
with events_session(organizer_token) as api:
res = api.CreateEvent(
events_pb2.CreateEventReq(
title="Dummy event",
content="Dummy content.",
location=events_pb2.EventLocation(
address="Near Null Island",
lat=0.1,
lng=0.2,
),
start_time=Timestamp_from_datetime(start_time),
end_time=Timestamp_from_datetime(end_time),
timezone="UTC",
)
)
return int(res.thread.thread_id)


def test_post_reply_incomplete_profile_blocked_on_event_comment(db):
"""An incomplete-profile user cannot post a top-level comment on an event's thread."""
organizer, organizer_token = generate_user()
_commenter, commenter_token = generate_user(complete_profile=False)

event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)

with threads_session(commenter_token) as api:
with pytest.raises(grpc.RpcError) as e:
api.PostReply(threads_pb2.PostReplyReq(thread_id=event_thread_id, content="hello"))
assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION


def test_post_reply_incomplete_profile_blocked_on_event_reply(db):
"""An incomplete-profile user cannot post a nested reply within an event's thread."""
organizer, organizer_token = generate_user()
_commenter, commenter_token = generate_user()
_replier, replier_token = generate_user(complete_profile=False)

event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)

with threads_session(commenter_token) as api:
comment_thread_id = api.PostReply(
threads_pb2.PostReplyReq(thread_id=event_thread_id, content="top-level comment")
).thread_id

with threads_session(replier_token) as api:
with pytest.raises(grpc.RpcError) as e:
api.PostReply(threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="a reply"))
assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION


def test_post_reply_complete_profile_allowed_on_event_comment(db):
"""A complete-profile user can post a comment on an event's thread."""
organizer, organizer_token = generate_user()
_commenter, commenter_token = generate_user()

event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)

with threads_session(commenter_token) as api:
comment_thread_id = api.PostReply(
threads_pb2.PostReplyReq(thread_id=event_thread_id, content="hello")
).thread_id
assert comment_thread_id


def test_post_reply_incomplete_profile_blocked_on_discussion_thread(db):
"""An incomplete-profile user cannot post a comment on a discussion thread."""
admin, admin_token = generate_user()
_commenter, commenter_token = generate_user(complete_profile=False)

with session_scope() as session:
community_id = create_community(session, 0, 1, "Testing Community", [admin], [], None).id

with discussions_session(admin_token) as api:
discussion = api.CreateDiscussion(
discussions_pb2.CreateDiscussionReq(
title="A discussion",
content="Content",
owner_community_id=community_id,
)
)
discussion_thread_id = discussion.thread.thread_id

with threads_session(commenter_token) as api:
with pytest.raises(grpc.RpcError) as e:
api.PostReply(threads_pb2.PostReplyReq(thread_id=discussion_thread_id, content="hello"))
assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION


def test_edit_comment_multiple_edits_creates_multiple_version_records(db):
user, token = generate_user()
_, comment_thread_id = _make_thread_and_comment(token, content="v1")
Expand Down
13 changes: 13 additions & 0 deletions app/web/components/Comments/NewComment.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
import { Box, Button, Grid, Link } from "@mui/material";
import Markdown from "components/Markdown";
import ProfileIncompleteDialog from "components/ProfileIncompleteDialog/ProfileIncompleteDialog";
import TextField from "components/TextField";
import useAccountInfo from "features/auth/useAccountInfo";
import React, { useState } from "react";

interface NewCommentProps {
onComment: (comment: string) => Promise<void>;
}

export default function NewComment({ onComment }: NewCommentProps) {
const { data: accountInfo } = useAccountInfo();
const [preview, setPreview] = useState(false);
const [comment, setComment] = useState("");
const [profileDialogOpen, setProfileDialogOpen] = useState(false);

const handleSubmit = async () => {
if (accountInfo?.profileComplete === false) {
setProfileDialogOpen(true);
return;
}
await onComment(comment);
setComment("");
};

return (
<>
<ProfileIncompleteDialog
open={profileDialogOpen}
onClose={() => setProfileDialogOpen(false)}
attempted_action="post_comment"
/>
<p>Write a comment:</p>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: preview ? 6 : 12 }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@ import Link from "next/link";
import React from "react";
import { howToCompleteProfileUrl, routeToEditProfile } from "routes";

export type ProfileIncompleteAction =
| "create_event"
| "send_message"
| "send_request"
| "create_public_trip"
| "send_friend_request"
| "create_discussion"
| "post_comment";

interface ProfileIncompleteDialogProps {
open: boolean;
onClose: () => void;
attempted_action:
| "create_event"
| "send_message"
| "send_request"
| "create_public_trip"
| "send_friend_request"
| "create_discussion";
attempted_action: ProfileIncompleteAction;
}

export default function ProfileIncompleteDialog({
Expand Down
1 change: 0 additions & 1 deletion app/web/features/communities/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const StyledWrapper = styled("div")(({ theme }) => ({
},
[theme.breakpoints.up("md")]: {
height: "16rem",
marginTop: theme.spacing(-2),
Comment thread
kevinortiz43 marked this conversation as resolved.
},
}));

Expand Down
65 changes: 65 additions & 0 deletions app/web/features/communities/discussions/CommentForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { discussionBaseRoute } from "routes";
import { service } from "service";
import wrapper from "test/hookWrapper";
import i18n from "test/i18n";
import { getAccountInfo } from "test/serviceMockDefaults";
import { MockedService } from "test/utils";

import CommentForm from "./CommentForm";
Expand All @@ -16,6 +17,9 @@ jest.mock("components/MarkdownInput");
const postReplyMock = service.threads.postReply as MockedService<
typeof service.threads.postReply
>;
const getAccountInfoMock = service.account.getAccountInfo as MockedService<
typeof service.account.getAccountInfo
>;

function renderCommentForm() {
mockRouter.setCurrentUrl(
Expand All @@ -24,6 +28,11 @@ function renderCommentForm() {
render(<CommentForm threadId={999} shown={true} />, { wrapper });
}

beforeEach(() => {
// Default to a complete profile so non-gate tests don't trip the incomplete-profile dialog.
getAccountInfoMock.mockImplementation(getAccountInfo);
});

describe("Comment form", () => {
beforeAll(() => {
jest.useFakeTimers();
Expand Down Expand Up @@ -96,3 +105,59 @@ describe("Comment form", () => {
expect(postReplyMock).not.toHaveBeenCalled();
});
});

describe("Comment form profile-completeness gate", () => {
beforeAll(() => {
// The "Comment form" describe above leaves fake timers active; restore
// real timers so `await user.type`/`user.click` resolve normally here.
jest.useRealTimers();
});

beforeEach(() => {
postReplyMock.mockClear();
});

it("shows the profile incomplete dialog instead of posting when the profile is incomplete", async () => {
getAccountInfoMock.mockImplementation(async () => ({
...(await getAccountInfo()),
profileComplete: false,
}));
renderCommentForm();

const user = userEvent.setup();

const commentInput = (await screen.findByLabelText(
t("communities:write_comment_a11y_label"),
)) as HTMLInputElement;
await user.type(commentInput, "This is a valid comment");

await user.click(
screen.getByRole("button", { name: t("communities:comment") }),
);

expect(
await screen.findByLabelText(t("profile:complete_profile_dialog.title")),
).toBeVisible();
expect(postReplyMock).not.toHaveBeenCalled();
});

it("posts the comment as usual when the profile is complete", async () => {
renderCommentForm();

const user = userEvent.setup();

const commentInput = (await screen.findByLabelText(
t("communities:write_comment_a11y_label"),
)) as HTMLInputElement;
await user.type(commentInput, "This is a valid comment");

await user.click(
screen.getByRole("button", { name: t("communities:comment") }),
);

await waitFor(() => expect(postReplyMock).toHaveBeenCalledTimes(1));
expect(
screen.queryByLabelText(t("profile:complete_profile_dialog.title")),
).not.toBeInTheDocument();
});
});
17 changes: 16 additions & 1 deletion app/web/features/communities/discussions/CommentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import Alert from "components/Alert";
import Button from "components/Button";
import MarkdownInput, { MarkdownInputProps } from "components/MarkdownInput";
import ProfileIncompleteDialog from "components/ProfileIncompleteDialog/ProfileIncompleteDialog";
import useAccountInfo from "features/auth/useAccountInfo";
import { RpcError } from "grpc-web";
import { useTranslation } from "i18n";
import { COMMUNITIES, GLOBAL } from "i18n/namespaces";
import React, { useRef } from "react";
import React, { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { service } from "service";
import { theme } from "theme";
Expand Down Expand Up @@ -48,6 +50,9 @@ function InternalCommentForm(
ref: React.ForwardedRef<HTMLFormElement>,
) {
const { t } = useTranslation([GLOBAL, COMMUNITIES]);
const { data: accountInfo } = useAccountInfo();
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const profileIncomplete = accountInfo?.profileComplete === false;
const {
control,
handleSubmit,
Expand Down Expand Up @@ -75,6 +80,11 @@ function InternalCommentForm(
});

const onSubmit = handleSubmit((data) => {
if (profileIncomplete) {
setProfileDialogOpen(true);
return;
}

const trimmedValue = data.content.trim();
const newData = {
content: trimmedValue,
Expand All @@ -85,6 +95,11 @@ function InternalCommentForm(

return (
<Collapse data-testid={`comment-${threadId}-comment-form`} in={shown}>
<ProfileIncompleteDialog
open={profileDialogOpen}
onClose={() => setProfileDialogOpen(false)}
attempted_action="post_comment"
/>
<StyledForm onSubmit={onSubmit} ref={ref}>
{error && <Alert severity="error">{error.message}</Alert>}
<span style={visuallyHidden} id={`comment-${threadId}-reply-label`}>
Expand Down
3 changes: 2 additions & 1 deletion app/web/features/profile/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,8 @@
"send_friend_request": "send a friend request",
"create_event": "create an event",
"create_public_trip": "create a public trip",
"create_discussion": "create a discussion"
"create_discussion": "create a discussion",
"post_comment": "post a comment"
},
"edit_profile_button": "Edit your profile now",
"cancel_button": "Never mind"
Expand Down
Loading