Files
zulip/zerver/tests/test_message_move_topic.py
Anders Kaseorg f24a0a6b81 ruff: Fix RUF059 Unpacked variable is never used.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-09-30 16:47:54 -07:00

2870 lines
114 KiB
Python

from datetime import timedelta
from typing import Any
from unittest import mock
import orjson
import time_machine
from django.test import override_settings
from django.utils.timezone import now as timezone_now
from zerver.actions.message_delete import do_delete_messages
from zerver.actions.message_edit import (
build_message_edit_request,
check_update_message,
do_update_message,
maybe_send_resolve_topic_notifications,
)
from zerver.actions.reactions import do_add_reaction
from zerver.actions.realm_settings import (
do_change_realm_permission_group_setting,
do_set_realm_property,
)
from zerver.actions.streams import do_change_stream_group_based_setting, do_set_stream_property
from zerver.actions.user_groups import check_add_user_group
from zerver.actions.user_settings import do_change_user_setting
from zerver.actions.user_topics import do_set_user_topic_visibility_policy
from zerver.lib.message import truncate_topic
from zerver.lib.test_classes import ZulipTestCase, get_topic_messages
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.topic import RESOLVED_TOPIC_PREFIX
from zerver.lib.types import StreamMessageEditRequest, UserGroupMembersData
from zerver.lib.user_topics import (
get_users_with_user_topic_visibility_policy,
set_topic_visibility_policy,
topic_has_visibility_policy,
)
from zerver.lib.utils import assert_is_not_none
from zerver.models import Message, UserMessage, UserProfile, UserTopic
from zerver.models.constants import MAX_TOPIC_NAME_LENGTH
from zerver.models.groups import NamedUserGroup, SystemGroups
from zerver.models.realms import RealmTopicsPolicyEnum
from zerver.models.streams import Stream, StreamTopicsPolicyEnum, get_stream
from zerver.models.users import ResolvedTopicNoticeAutoReadPolicyEnum
class MessageMoveTopicTest(ZulipTestCase):
def check_topic(self, msg_id: int, topic_name: str) -> None:
msg = Message.objects.get(id=msg_id)
self.assertEqual(msg.topic_name(), topic_name)
def assert_move_message(
self,
user: str,
orig_stream: Stream,
orig_topic_name: str = "test",
stream_id: int | None = None,
topic_name: str | None = None,
expected_error: str | None = None,
) -> None:
user_profile = self.example_user(user)
self.subscribe(user_profile, orig_stream.name)
message_id = self.send_stream_message(
user_profile, orig_stream.name, topic_name=orig_topic_name
)
params_dict: dict[str, str | int] = {}
if stream_id is not None:
params_dict["stream_id"] = stream_id
if topic_name is not None:
params_dict["topic"] = topic_name
result = self.api_patch(
user_profile,
"/api/v1/messages/" + str(message_id),
params_dict,
)
if expected_error is not None:
self.assert_json_error(result, expected_error)
else:
self.assert_json_success(result)
def assert_has_visibility_policy(
self,
user_profile: UserProfile,
topic_name: str,
stream: Stream,
visibility_policy: int,
*,
expected: bool = True,
) -> None:
if expected:
self.assertTrue(
topic_has_visibility_policy(user_profile, stream.id, topic_name, visibility_policy)
)
else:
self.assertFalse(
topic_has_visibility_policy(user_profile, stream.id, topic_name, visibility_policy)
)
def test_private_message_edit_topic(self) -> None:
hamlet = self.example_user("hamlet")
self.login("hamlet")
cordelia = self.example_user("cordelia")
msg_id = self.send_personal_message(hamlet, cordelia)
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": "Should not exist",
},
)
self.assert_json_error(result, "Direct messages cannot have topics.")
def test_propagate_invalid(self) -> None:
self.login("hamlet")
id1 = self.send_stream_message(self.example_user("hamlet"), "Denmark", topic_name="topic1")
result = self.client_patch(
"/json/messages/" + str(id1),
{
"topic": "edited",
"propagate_mode": "invalid",
},
)
self.assert_json_error(result, "Invalid propagate_mode")
self.check_topic(id1, topic_name="topic1")
result = self.client_patch(
"/json/messages/" + str(id1),
{
"content": "edited",
"propagate_mode": "change_all",
},
)
self.assert_json_error(result, "Invalid propagate_mode without topic edit")
self.check_topic(id1, topic_name="topic1")
def test_edit_message_empty_topic_with_extra_space(self) -> None:
self.login("hamlet")
msg_id = self.send_stream_message(
self.example_user("hamlet"), "Denmark", topic_name="editing", content="before edit"
)
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": " ",
},
)
self.assert_json_success(result)
self.check_topic(msg_id, "")
def test_topic_required_in_mandatory_topic_realm(self) -> None:
admin_user = self.example_user("iago")
hamlet = self.example_user("hamlet")
realm = admin_user.realm
self.login_user(admin_user)
stream = self.make_stream("new_stream")
stream_mandatory_topics = self.make_stream(
"topics_required", topics_policy=StreamTopicsPolicyEnum.disable_empty_topic.value
)
self.subscribe(admin_user, stream.name)
self.subscribe(hamlet, stream.name)
original_topic_name = "topic 1"
message_id = self.send_stream_message(
hamlet,
stream.name,
topic_name=original_topic_name,
)
# Verify with topics_policy=disable_empty_topic:
# * A topic can't be moved to an empty topic
# * A topic can be moved to a non-empty topic
do_set_realm_property(
realm,
"topics_policy",
RealmTopicsPolicyEnum.disable_empty_topic,
acting_user=admin_user,
)
for topic_name in ["(no topic)", ""]:
result = self.client_patch(
f"/json/messages/{message_id}",
{
"topic": topic_name,
},
)
self.assert_json_error(
result, "Sending messages to the general chat is not allowed in this channel."
)
self.check_topic(message_id, topic_name=original_topic_name)
new_topic_name = "new valid topic"
result = self.client_patch(
f"/json/messages/{message_id}",
{
"topic": new_topic_name,
},
)
self.assert_json_success(result)
self.check_topic(message_id, new_topic_name)
# Verify with topics_policy=allow_empty_topic:
# * A topic can be moved to an empty topic
# * A topic can be moved to a non-empty topic
do_set_realm_property(
realm, "topics_policy", RealmTopicsPolicyEnum.allow_empty_topic, acting_user=admin_user
)
for topic_name in ["(no topic)", "", "non-empty topic"]:
result = self.client_patch(
f"/json/messages/{message_id}",
{
"topic": topic_name,
},
)
self.assert_json_success(result)
self.check_topic(message_id, topic_name)
# Test that message cannot be moved to empty topic in stream with
# `topics_policy=disable_empty_topic`.
for topic_name in ["(no topic)", ""]:
result = self.client_patch(
f"/json/messages/{message_id}",
{"topic": topic_name, "stream_id": stream_mandatory_topics.id},
)
self.assert_json_error(
result, "Sending messages to the general chat is not allowed in this channel."
)
self.check_topic(message_id, topic_name="non-empty topic")
# Test that message cannot be moved to empty topic in stream with
# `topics_policy=disable_empty_topic` when `topic_name` is `None`.
for topic_name in ["(no topic)", ""]:
message_id = self.send_stream_message(
hamlet,
stream.name,
topic_name=topic_name,
)
result = self.client_patch(
f"/json/messages/{message_id}",
{"stream_id": stream_mandatory_topics.id},
)
self.assert_json_error(
result, "Sending messages to the general chat is not allowed in this channel."
)
self.check_topic(message_id, topic_name="")
def test_edit_message_invalid_topic(self) -> None:
self.login("hamlet")
msg_id = self.send_stream_message(
self.example_user("hamlet"), "Denmark", topic_name="editing", content="before edit"
)
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": "editing\nfun",
},
)
self.assert_json_error(result, "Invalid character in topic, at position 8!")
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": f"{Message.DM_TOPIC}",
},
)
self.assert_json_error(result, "Invalid character in topic, at position 1!")
@mock.patch("zerver.actions.message_edit.send_event_on_commit")
def test_edit_topic_public_history_stream(self, mock_send_event: mock.MagicMock) -> None:
stream_name = "Macbeth"
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
self.make_stream(stream_name, history_public_to_subscribers=True)
self.subscribe(hamlet, stream_name)
self.login_user(hamlet)
message_id = self.send_stream_message(hamlet, stream_name, "Where am I?")
self.login_user(cordelia)
self.subscribe(cordelia, stream_name)
message = Message.objects.get(id=message_id)
def do_update_message_topic_success(
user_profile: UserProfile,
message: Message,
topic_name: str,
users_to_be_notified: list[dict[str, Any]],
) -> None:
message_edit_request = build_message_edit_request(
message=message,
user_profile=user_profile,
propagate_mode="change_later",
stream_id=None,
topic_name=topic_name,
content=None,
)
do_update_message(
user_profile=user_profile,
target_message=message,
message_edit_request=message_edit_request,
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
rendering_result=None,
prior_mention_user_ids=set(),
mention_data=None,
)
mock_send_event.assert_called_with(mock.ANY, mock.ANY, users_to_be_notified)
# Returns the users that need to be notified when a message topic is changed
def notify(user_id: int) -> dict[str, Any]:
um = UserMessage.objects.get(message=message_id)
if um.user_profile_id == user_id:
return {
"id": user_id,
"flags": um.flags_list(),
}
else:
return {
"id": user_id,
"flags": ["read"],
}
users_to_be_notified = list(map(notify, [hamlet.id, cordelia.id]))
# Edit topic of a message sent before Cordelia subscribed the stream
do_update_message_topic_success(
cordelia, message, "Othello eats apple", users_to_be_notified
)
# If Cordelia is long-term idle, she doesn't get a notification.
cordelia.long_term_idle = True
cordelia.save()
users_to_be_notified = list(map(notify, [hamlet.id]))
do_update_message_topic_success(
cordelia, message, "Another topic idle", users_to_be_notified
)
cordelia.long_term_idle = False
cordelia.save()
# Even if Hamlet unsubscribes the stream, he should be notified when the topic is changed
# because he has a UserMessage row.
self.unsubscribe(hamlet, stream_name)
users_to_be_notified = list(map(notify, [hamlet.id, cordelia.id]))
do_update_message_topic_success(cordelia, message, "Another topic", users_to_be_notified)
# Hamlet subscribes to the stream again and Cordelia unsubscribes, then Hamlet changes
# the message topic. Cordelia won't receive any updates when a message on that stream is
# changed because she is not a subscriber and doesn't have a UserMessage row.
self.subscribe(hamlet, stream_name)
self.unsubscribe(cordelia, stream_name)
self.login_user(hamlet)
users_to_be_notified = list(map(notify, [hamlet.id]))
do_update_message_topic_success(hamlet, message, "Change again", users_to_be_notified)
@mock.patch("zerver.actions.user_topics.send_event_on_commit")
def test_edit_muted_topic(self, mock_send_event_on_commit: mock.MagicMock) -> None:
stream_name = "Stream 123"
stream = self.make_stream(stream_name)
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
aaron = self.example_user("aaron")
self.subscribe(hamlet, stream_name)
self.login_user(hamlet)
message_id = self.send_stream_message(
hamlet, stream_name, topic_name="Topic1", content="Hello World"
)
self.subscribe(cordelia, stream_name)
self.login_user(cordelia)
self.subscribe(aaron, stream_name)
self.login_user(aaron)
def assert_is_topic_muted(
user_profile: UserProfile,
stream_id: int,
topic_name: str,
*,
muted: bool,
) -> None:
if muted:
self.assertTrue(
topic_has_visibility_policy(
user_profile, stream_id, topic_name, UserTopic.VisibilityPolicy.MUTED
)
)
else:
self.assertFalse(
topic_has_visibility_policy(
user_profile, stream_id, topic_name, UserTopic.VisibilityPolicy.MUTED
)
)
already_muted_topic_name = "Already muted topic"
muted_topics = [
[stream_name, "Topic1"],
[stream_name, "Topic2"],
[stream_name, already_muted_topic_name],
]
set_topic_visibility_policy(hamlet, muted_topics, UserTopic.VisibilityPolicy.MUTED)
set_topic_visibility_policy(cordelia, muted_topics, UserTopic.VisibilityPolicy.MUTED)
# users that need to be notified by send_event in the case of change-topic-name operation.
users_to_be_notified_via_muted_topics_event: list[int] = []
users_to_be_notified_via_user_topic_event: list[int] = []
for user_topic in get_users_with_user_topic_visibility_policy(stream.id, "Topic1"):
# We are appending the same data twice because 'user_topic' event notifies
# the user during delete and create operation.
users_to_be_notified_via_user_topic_event.append(user_topic.user_profile_id)
users_to_be_notified_via_user_topic_event.append(user_topic.user_profile_id)
# 'muted_topics' event notifies the user of muted topics during create
# operation only.
users_to_be_notified_via_muted_topics_event.append(user_topic.user_profile_id)
change_all_topic_name = "Topic 1 edited"
# Verify how many total database queries are required. We
# expect 6 queries (4/visibility_policy to update the muted
# state + 1/user with a UserTopic row for the events data)
# beyond what is typical were there not UserTopic records to
# update. Ideally, we'd eliminate the per-user component.
with self.assert_database_query_count(27):
check_update_message(
user_profile=hamlet,
message_id=message_id,
stream_id=None,
topic_name=change_all_topic_name,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
# Extract the send_event call where event type is 'user_topic' or 'muted_topics.
# Here we assert that the expected users are notified properly.
users_notified_via_muted_topics_event: list[int] = []
users_notified_via_user_topic_event: list[int] = []
for call_args in mock_send_event_on_commit.call_args_list:
(_arg_realm, arg_event, arg_notified_users) = call_args[0]
if arg_event["type"] == "user_topic":
users_notified_via_user_topic_event.append(*arg_notified_users)
elif arg_event["type"] == "muted_topics":
users_notified_via_muted_topics_event.append(*arg_notified_users)
self.assertEqual(
sorted(users_notified_via_muted_topics_event),
sorted(users_to_be_notified_via_muted_topics_event),
)
self.assertEqual(
sorted(users_notified_via_user_topic_event),
sorted(users_to_be_notified_via_user_topic_event),
)
assert_is_topic_muted(hamlet, stream.id, "Topic1", muted=False)
assert_is_topic_muted(cordelia, stream.id, "Topic1", muted=False)
assert_is_topic_muted(aaron, stream.id, "Topic1", muted=False)
assert_is_topic_muted(hamlet, stream.id, "Topic2", muted=True)
assert_is_topic_muted(cordelia, stream.id, "Topic2", muted=True)
assert_is_topic_muted(aaron, stream.id, "Topic2", muted=False)
assert_is_topic_muted(hamlet, stream.id, change_all_topic_name, muted=True)
assert_is_topic_muted(cordelia, stream.id, change_all_topic_name, muted=True)
assert_is_topic_muted(aaron, stream.id, change_all_topic_name, muted=False)
change_later_topic_name = "Topic 1 edited again"
check_update_message(
user_profile=hamlet,
message_id=message_id,
stream_id=None,
topic_name=change_later_topic_name,
propagate_mode="change_later",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
assert_is_topic_muted(hamlet, stream.id, change_all_topic_name, muted=False)
assert_is_topic_muted(hamlet, stream.id, change_later_topic_name, muted=True)
# Make sure we safely handle the case of the new topic being already muted.
check_update_message(
user_profile=hamlet,
message_id=message_id,
stream_id=None,
topic_name=already_muted_topic_name,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
assert_is_topic_muted(hamlet, stream.id, change_later_topic_name, muted=False)
assert_is_topic_muted(hamlet, stream.id, already_muted_topic_name, muted=True)
change_one_topic_name = "Topic 1 edited change_one"
check_update_message(
user_profile=hamlet,
message_id=message_id,
stream_id=None,
topic_name=change_one_topic_name,
propagate_mode="change_one",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
assert_is_topic_muted(hamlet, stream.id, change_one_topic_name, muted=True)
assert_is_topic_muted(hamlet, stream.id, change_later_topic_name, muted=False)
# Move topic between two public streams.
desdemona = self.example_user("desdemona")
message_id = self.send_stream_message(
hamlet, stream_name, topic_name="New topic", content="Hello World"
)
new_public_stream = self.make_stream("New public stream")
self.subscribe(desdemona, new_public_stream.name)
self.login_user(desdemona)
muted_topics = [
[stream_name, "New topic"],
]
set_topic_visibility_policy(desdemona, muted_topics, UserTopic.VisibilityPolicy.MUTED)
set_topic_visibility_policy(cordelia, muted_topics, UserTopic.VisibilityPolicy.MUTED)
with self.assert_database_query_count(29):
check_update_message(
user_profile=desdemona,
message_id=message_id,
stream_id=new_public_stream.id,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
assert_is_topic_muted(desdemona, stream.id, "New topic", muted=False)
assert_is_topic_muted(cordelia, stream.id, "New topic", muted=False)
assert_is_topic_muted(aaron, stream.id, "New topic", muted=False)
assert_is_topic_muted(desdemona, new_public_stream.id, "New topic", muted=True)
assert_is_topic_muted(cordelia, new_public_stream.id, "New topic", muted=True)
assert_is_topic_muted(aaron, new_public_stream.id, "New topic", muted=False)
# Move topic to a private stream.
message_id = self.send_stream_message(
hamlet, stream_name, topic_name="New topic", content="Hello World"
)
new_private_stream = self.make_stream("New private stream", invite_only=True)
self.subscribe(desdemona, new_private_stream.name)
self.login_user(desdemona)
muted_topics = [
[stream_name, "New topic"],
]
set_topic_visibility_policy(desdemona, muted_topics, UserTopic.VisibilityPolicy.MUTED)
set_topic_visibility_policy(cordelia, muted_topics, UserTopic.VisibilityPolicy.MUTED)
with self.assert_database_query_count(35):
check_update_message(
user_profile=desdemona,
message_id=message_id,
stream_id=new_private_stream.id,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
# Cordelia is not subscribed to the private stream, so
# Cordelia should have had the topic unmuted, while Desdemona
# should have had her muted topic record moved.
assert_is_topic_muted(desdemona, stream.id, "New topic", muted=False)
assert_is_topic_muted(cordelia, stream.id, "New topic", muted=False)
assert_is_topic_muted(aaron, stream.id, "New topic", muted=False)
assert_is_topic_muted(desdemona, new_private_stream.id, "New topic", muted=True)
assert_is_topic_muted(cordelia, new_private_stream.id, "New topic", muted=False)
assert_is_topic_muted(aaron, new_private_stream.id, "New topic", muted=False)
# Move topic between two public streams with change in topic name.
desdemona = self.example_user("desdemona")
message_id = self.send_stream_message(
hamlet, stream_name, topic_name="New topic 2", content="Hello World"
)
self.login_user(desdemona)
muted_topics = [
[stream_name, "New topic 2"],
]
set_topic_visibility_policy(desdemona, muted_topics, UserTopic.VisibilityPolicy.MUTED)
set_topic_visibility_policy(cordelia, muted_topics, UserTopic.VisibilityPolicy.MUTED)
with self.assert_database_query_count(31):
check_update_message(
user_profile=desdemona,
message_id=message_id,
stream_id=new_public_stream.id,
topic_name="changed topic name",
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
assert_is_topic_muted(desdemona, stream.id, "New topic 2", muted=False)
assert_is_topic_muted(cordelia, stream.id, "New topic 2", muted=False)
assert_is_topic_muted(aaron, stream.id, "New topic 2", muted=False)
assert_is_topic_muted(desdemona, new_public_stream.id, "changed topic name", muted=True)
assert_is_topic_muted(cordelia, new_public_stream.id, "changed topic name", muted=True)
assert_is_topic_muted(aaron, new_public_stream.id, "changed topic name", muted=False)
# Moving only half the messages doesn't move UserTopic records.
second_message_id = self.send_stream_message(
hamlet, stream_name, topic_name="changed topic name", content="Second message"
)
with self.assert_database_query_count(25):
check_update_message(
user_profile=desdemona,
message_id=second_message_id,
stream_id=new_public_stream.id,
topic_name="final topic name",
propagate_mode="change_later",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
assert_is_topic_muted(desdemona, new_public_stream.id, "changed topic name", muted=True)
assert_is_topic_muted(cordelia, new_public_stream.id, "changed topic name", muted=True)
assert_is_topic_muted(aaron, new_public_stream.id, "changed topic name", muted=False)
assert_is_topic_muted(desdemona, new_public_stream.id, "final topic name", muted=False)
assert_is_topic_muted(cordelia, new_public_stream.id, "final topic name", muted=False)
assert_is_topic_muted(aaron, new_public_stream.id, "final topic name", muted=False)
@mock.patch("zerver.actions.user_topics.send_event_on_commit")
def test_edit_unmuted_topic(self, mock_send_event_on_commit: mock.MagicMock) -> None:
stream_name = "Stream 123"
stream = self.make_stream(stream_name)
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
aaron = self.example_user("aaron")
othello = self.example_user("othello")
self.subscribe(hamlet, stream_name)
self.login_user(hamlet)
message_id = self.send_stream_message(
hamlet, stream_name, topic_name="Topic1", content="Hello World"
)
self.subscribe(cordelia, stream_name)
self.login_user(cordelia)
self.subscribe(aaron, stream_name)
self.login_user(aaron)
self.subscribe(othello, stream_name)
self.login_user(othello)
# Initially, hamlet and othello set visibility_policy as UNMUTED for 'Topic1' and 'Topic2',
# cordelia sets visibility_policy as MUTED for 'Topic1' and 'Topic2', while
# aaron doesn't have a visibility_policy set for 'Topic1' or 'Topic2'.
#
# After moving messages from 'Topic1' to 'Topic 1 edited', the expected behaviour is:
# hamlet and othello have UNMUTED 'Topic 1 edited' and no visibility_policy set for 'Topic1'
# cordelia has MUTED 'Topic 1 edited' and no visibility_policy set for 'Topic1'
#
# There is no change in visibility_policy configurations for 'Topic2', i.e.
# hamlet and othello have UNMUTED 'Topic2' + cordelia has MUTED 'Topic2'
# aaron still doesn't have visibility_policy set for any topic.
#
# Note: We have used two users with UNMUTED 'Topic1' to verify that the query count
# doesn't increase (in order to update UserTopic records) with an increase in users.
# (We are using bulk database operations.)
# 1 query/user is added in order to send muted_topics event.(which will be deprecated)
topics = [
[stream_name, "Topic1"],
[stream_name, "Topic2"],
]
set_topic_visibility_policy(hamlet, topics, UserTopic.VisibilityPolicy.UNMUTED)
set_topic_visibility_policy(cordelia, topics, UserTopic.VisibilityPolicy.MUTED)
set_topic_visibility_policy(othello, topics, UserTopic.VisibilityPolicy.UNMUTED)
# users that need to be notified by send_event in the case of change-topic-name operation.
users_to_be_notified_via_muted_topics_event: list[int] = []
users_to_be_notified_via_user_topic_event: list[int] = []
for user_topic in get_users_with_user_topic_visibility_policy(stream.id, "Topic1"):
# We are appending the same data twice because 'user_topic' event notifies
# the user during delete and create operation.
users_to_be_notified_via_user_topic_event.append(user_topic.user_profile_id)
users_to_be_notified_via_user_topic_event.append(user_topic.user_profile_id)
# 'muted_topics' event notifies the user of muted topics during create
# operation only.
users_to_be_notified_via_muted_topics_event.append(user_topic.user_profile_id)
change_all_topic_name = "Topic 1 edited"
with self.assert_database_query_count(32):
check_update_message(
user_profile=hamlet,
message_id=message_id,
stream_id=None,
topic_name=change_all_topic_name,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
# Extract the send_event call where event type is 'user_topic' or 'muted_topics.
# Here we assert that the expected users are notified properly.
users_notified_via_muted_topics_event: list[int] = []
users_notified_via_user_topic_event: list[int] = []
for call_args in mock_send_event_on_commit.call_args_list:
(_arg_realm, arg_event, arg_notified_users) = call_args[0]
if arg_event["type"] == "user_topic":
users_notified_via_user_topic_event.append(*arg_notified_users)
elif arg_event["type"] == "muted_topics":
users_notified_via_muted_topics_event.append(*arg_notified_users)
self.assertEqual(
sorted(users_notified_via_muted_topics_event),
sorted(users_to_be_notified_via_muted_topics_event),
)
self.assertEqual(
sorted(users_notified_via_user_topic_event),
sorted(users_to_be_notified_via_user_topic_event),
)
# No visibility_policy set for 'Topic1'
self.assert_has_visibility_policy(
hamlet, "Topic1", stream, UserTopic.VisibilityPolicy.UNMUTED, expected=False
)
self.assert_has_visibility_policy(
cordelia, "Topic1", stream, UserTopic.VisibilityPolicy.MUTED, expected=False
)
self.assert_has_visibility_policy(
othello, "Topic1", stream, UserTopic.VisibilityPolicy.UNMUTED, expected=False
)
self.assert_has_visibility_policy(
aaron, "Topic1", stream, UserTopic.VisibilityPolicy.UNMUTED, expected=False
)
# No change in visibility_policy configurations for 'Topic2'
self.assert_has_visibility_policy(
hamlet, "Topic2", stream, UserTopic.VisibilityPolicy.UNMUTED, expected=True
)
self.assert_has_visibility_policy(
cordelia, "Topic2", stream, UserTopic.VisibilityPolicy.MUTED, expected=True
)
self.assert_has_visibility_policy(
othello, "Topic2", stream, UserTopic.VisibilityPolicy.UNMUTED, expected=True
)
self.assert_has_visibility_policy(
aaron, "Topic2", stream, UserTopic.VisibilityPolicy.UNMUTED, expected=False
)
# UserTopic records moved to 'Topic 1 edited' after move-topic operation.
self.assert_has_visibility_policy(
hamlet, change_all_topic_name, stream, UserTopic.VisibilityPolicy.UNMUTED, expected=True
)
self.assert_has_visibility_policy(
cordelia, change_all_topic_name, stream, UserTopic.VisibilityPolicy.MUTED, expected=True
)
self.assert_has_visibility_policy(
othello,
change_all_topic_name,
stream,
UserTopic.VisibilityPolicy.UNMUTED,
expected=True,
)
self.assert_has_visibility_policy(
aaron, change_all_topic_name, stream, UserTopic.VisibilityPolicy.MUTED, expected=False
)
def test_merge_user_topic_states_on_move_messages(self) -> None:
stream_name = "Stream 123"
stream = self.make_stream(stream_name)
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
aaron = self.example_user("aaron")
shiva = self.example_user("shiva")
self.subscribe(hamlet, stream_name)
self.login_user(hamlet)
self.subscribe(cordelia, stream_name)
self.login_user(cordelia)
self.subscribe(aaron, stream_name)
self.login_user(aaron)
# Test the following cases:
#
# orig_topic | target_topic | final behaviour
# INHERIT INHERIT INHERIT
# INHERIT MUTED INHERIT
# INHERIT UNMUTED UNMUTED
# INHERIT FOLLOWED FOLLOWED
orig_topic = "Topic1"
target_topic = "Topic1 edited"
orig_message_id = self.send_stream_message(
hamlet, stream_name, topic_name=orig_topic, content="Hello World"
)
self.send_stream_message(
hamlet, stream_name, topic_name=target_topic, content="Hello World 2"
)
# By default:
# visibility_policy of 'hamlet', 'cordelia', 'aaron' for 'orig_topic': INHERIT
# visibility_policy of 'hamlet' for 'target_topic': INHERIT
#
# So we don't need to manually set visibility_policy to INHERIT whenever required,
# here and later in this test.
do_set_user_topic_visibility_policy(
cordelia, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)
do_set_user_topic_visibility_policy(
aaron, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED
)
do_set_user_topic_visibility_policy(
shiva, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED
)
check_update_message(
user_profile=hamlet,
message_id=orig_message_id,
stream_id=None,
topic_name=target_topic,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
self.assert_has_visibility_policy(
hamlet, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
cordelia, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
aaron, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
shiva, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
hamlet, target_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
cordelia, target_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
aaron, target_topic, stream, UserTopic.VisibilityPolicy.UNMUTED
)
self.assert_has_visibility_policy(
shiva, target_topic, stream, UserTopic.VisibilityPolicy.FOLLOWED
)
# Test the following cases:
#
# orig_topic | target_topic | final behaviour
# MUTED INHERIT INHERIT
# MUTED MUTED MUTED
# MUTED UNMUTED UNMUTED
# MUTED FOLLOWED FOLLOWED
orig_topic = "Topic2"
target_topic = "Topic2 edited"
orig_message_id = self.send_stream_message(
hamlet, stream_name, topic_name=orig_topic, content="Hello World"
)
self.send_stream_message(
hamlet, stream_name, topic_name=target_topic, content="Hello World 2"
)
do_set_user_topic_visibility_policy(
hamlet, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)
do_set_user_topic_visibility_policy(
cordelia, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)
do_set_user_topic_visibility_policy(
aaron, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)
do_set_user_topic_visibility_policy(
shiva, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)
do_set_user_topic_visibility_policy(
cordelia, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)
do_set_user_topic_visibility_policy(
aaron, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED
)
do_set_user_topic_visibility_policy(
shiva, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED
)
check_update_message(
user_profile=hamlet,
message_id=orig_message_id,
stream_id=None,
topic_name=target_topic,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
self.assert_has_visibility_policy(
hamlet, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
cordelia, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
aaron, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
shiva, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
hamlet, target_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
cordelia, target_topic, stream, UserTopic.VisibilityPolicy.MUTED
)
self.assert_has_visibility_policy(
aaron, target_topic, stream, UserTopic.VisibilityPolicy.UNMUTED
)
self.assert_has_visibility_policy(
shiva, target_topic, stream, UserTopic.VisibilityPolicy.FOLLOWED
)
# Test the following cases:
#
# orig_topic | target_topic | final behaviour
# UNMUTED INHERIT UNMUTED
# UNMUTED MUTED UNMUTED
# UNMUTED UNMUTED UNMUTED
# UNMUTED FOLLOWED FOLLOWED
orig_topic = "Topic3"
target_topic = "Topic3 edited"
orig_message_id = self.send_stream_message(
hamlet, stream_name, topic_name=orig_topic, content="Hello World"
)
self.send_stream_message(
hamlet, stream_name, topic_name=target_topic, content="Hello World 2"
)
do_set_user_topic_visibility_policy(
hamlet, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED
)
do_set_user_topic_visibility_policy(
cordelia, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED
)
do_set_user_topic_visibility_policy(
aaron, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED
)
do_set_user_topic_visibility_policy(
shiva, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED
)
do_set_user_topic_visibility_policy(
cordelia, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)
do_set_user_topic_visibility_policy(
aaron, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED
)
do_set_user_topic_visibility_policy(
shiva, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED
)
check_update_message(
user_profile=hamlet,
message_id=orig_message_id,
stream_id=None,
topic_name=target_topic,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
self.assert_has_visibility_policy(
hamlet, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
cordelia, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
aaron, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
shiva, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
hamlet, target_topic, stream, UserTopic.VisibilityPolicy.UNMUTED
)
self.assert_has_visibility_policy(
cordelia, target_topic, stream, UserTopic.VisibilityPolicy.UNMUTED
)
self.assert_has_visibility_policy(
aaron, target_topic, stream, UserTopic.VisibilityPolicy.UNMUTED
)
self.assert_has_visibility_policy(
shiva, target_topic, stream, UserTopic.VisibilityPolicy.FOLLOWED
)
# Test the following cases:
#
# orig_topic | target_topic | final behaviour
# FOLLOWED INHERIT FOLLOWED
# FOLLOWED MUTED FOLLOWED
# FOLLOWED UNMUTED FOLLOWED
orig_topic = "Topic4"
target_topic = "Topic4 edited"
orig_message_id = self.send_stream_message(
hamlet, stream_name, topic_name=orig_topic, content="Hello World"
)
self.send_stream_message(
hamlet, stream_name, topic_name=target_topic, content="Hello World 2"
)
do_set_user_topic_visibility_policy(
cordelia, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED
)
do_set_user_topic_visibility_policy(
hamlet, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED
)
do_set_user_topic_visibility_policy(
cordelia, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED
)
do_set_user_topic_visibility_policy(
cordelia, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)
do_set_user_topic_visibility_policy(
aaron, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED
)
do_set_user_topic_visibility_policy(
aaron, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED
)
check_update_message(
user_profile=hamlet,
message_id=orig_message_id,
stream_id=None,
topic_name=target_topic,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
self.assert_has_visibility_policy(
hamlet, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
cordelia, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
aaron, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
hamlet, target_topic, stream, UserTopic.VisibilityPolicy.FOLLOWED
)
self.assert_has_visibility_policy(
cordelia, target_topic, stream, UserTopic.VisibilityPolicy.FOLLOWED
)
self.assert_has_visibility_policy(
aaron, target_topic, stream, UserTopic.VisibilityPolicy.FOLLOWED
)
def test_user_topic_states_on_moving_to_topic_with_no_messages(self) -> None:
stream_name = "Stream 123"
stream = self.make_stream(stream_name)
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
aaron = self.example_user("aaron")
self.subscribe(hamlet, stream_name)
self.subscribe(cordelia, stream_name)
self.subscribe(aaron, stream_name)
# Test the case where target topic has no messages:
#
# orig_topic | final behaviour
# INHERIT INHERIT
# UNMUTED UNMUTED
# MUTED MUTED
orig_topic = "Topic1"
target_topic = "Topic1 edited"
orig_message_id = self.send_stream_message(
hamlet, stream_name, topic_name=orig_topic, content="Hello World"
)
do_set_user_topic_visibility_policy(
hamlet, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED
)
do_set_user_topic_visibility_policy(
cordelia, stream, orig_topic, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)
check_update_message(
user_profile=hamlet,
message_id=orig_message_id,
stream_id=None,
topic_name=target_topic,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
self.assert_has_visibility_policy(
hamlet, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
cordelia, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
aaron, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
hamlet, target_topic, stream, UserTopic.VisibilityPolicy.UNMUTED
)
self.assert_has_visibility_policy(
cordelia, target_topic, stream, UserTopic.VisibilityPolicy.MUTED
)
self.assert_has_visibility_policy(
aaron, target_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
def test_user_topic_state_for_messages_deleted_from_target_topic(
orig_topic: str, target_topic: str, original_topic_state: int
) -> None:
# Test the case where target topic has no messages but has UserTopic row
# due to messages being deleted from the target topic.
orig_message_id = self.send_stream_message(
hamlet, stream_name, topic_name=orig_topic, content="Hello World"
)
target_message_id = self.send_stream_message(
hamlet, stream_name, topic_name=target_topic, content="Hello World"
)
if original_topic_state != UserTopic.VisibilityPolicy.INHERIT:
users = [hamlet, cordelia, aaron]
for user in users:
do_set_user_topic_visibility_policy(
user, stream, orig_topic, visibility_policy=original_topic_state
)
do_set_user_topic_visibility_policy(
hamlet, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.UNMUTED
)
do_set_user_topic_visibility_policy(
cordelia, stream, target_topic, visibility_policy=UserTopic.VisibilityPolicy.MUTED
)
# Delete the message in target topic to make it empty.
self.login("hamlet")
members_system_group = NamedUserGroup.objects.get(
name=SystemGroups.MEMBERS, realm_for_sharding=hamlet.realm, is_system_group=True
)
do_change_realm_permission_group_setting(
hamlet.realm,
"can_delete_own_message_group",
members_system_group,
acting_user=None,
)
self.client_delete(f"/json/messages/{target_message_id}")
check_update_message(
user_profile=hamlet,
message_id=orig_message_id,
stream_id=None,
topic_name=target_topic,
propagate_mode="change_all",
send_notification_to_old_thread=False,
send_notification_to_new_thread=False,
content=None,
)
self.assert_has_visibility_policy(
hamlet, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
cordelia, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(
aaron, orig_topic, stream, UserTopic.VisibilityPolicy.INHERIT
)
self.assert_has_visibility_policy(hamlet, target_topic, stream, original_topic_state)
self.assert_has_visibility_policy(cordelia, target_topic, stream, original_topic_state)
self.assert_has_visibility_policy(aaron, target_topic, stream, original_topic_state)
# orig_topic | target_topic | final behaviour
# INHERIT INHERIT INHERIT
# INHERIT UNMUTED INHERIT
# INHERIT MUTED INHERIT
test_user_topic_state_for_messages_deleted_from_target_topic(
orig_topic="Topic2",
target_topic="Topic2 edited",
original_topic_state=UserTopic.VisibilityPolicy.INHERIT,
)
# orig_topic | target_topic | final behaviour
# MUTED INHERIT MUTED
# MUTED UNMUTED MUTED
# MUTED MUTED MUTED
test_user_topic_state_for_messages_deleted_from_target_topic(
orig_topic="Topic3",
target_topic="Topic3 edited",
original_topic_state=UserTopic.VisibilityPolicy.MUTED,
)
# orig_topic | target_topic | final behaviour
# UNMUTED INHERIT UNMUTED
# UNMUTED UNMUTED UNMUTED
# UNMUTED MUTED UNMUTED
test_user_topic_state_for_messages_deleted_from_target_topic(
orig_topic="Topic4",
target_topic="Topic4 edited",
original_topic_state=UserTopic.VisibilityPolicy.UNMUTED,
)
def test_topic_edit_history_saved_in_all_message(self) -> None:
self.login("hamlet")
id1 = self.send_stream_message(self.example_user("hamlet"), "Denmark", topic_name="topic1")
id2 = self.send_stream_message(self.example_user("iago"), "Denmark", topic_name="topic1")
id3 = self.send_stream_message(self.example_user("iago"), "Verona", topic_name="topic1")
id4 = self.send_stream_message(self.example_user("hamlet"), "Denmark", topic_name="topic2")
id5 = self.send_stream_message(self.example_user("iago"), "Denmark", topic_name="topic1")
def verify_edit_history(new_topic_name: str, len_edit_history: int) -> None:
for msg_id in [id1, id2, id5]:
msg = Message.objects.get(id=msg_id)
self.assertEqual(
new_topic_name,
msg.topic_name(),
)
# Since edit history is being generated by do_update_message,
# it's contents can vary over time; So, to keep this test
# future proof, we only verify it's length.
self.assert_length(
orjson.loads(assert_is_not_none(msg.edit_history)), len_edit_history
)
for msg_id in [id3, id4]:
msg = Message.objects.get(id=msg_id)
self.assertEqual(msg.edit_history, None)
new_topic_name = "edited"
result = self.client_patch(
f"/json/messages/{id1}",
{
"topic": new_topic_name,
"propagate_mode": "change_later",
},
)
self.assert_json_success(result)
verify_edit_history(new_topic_name, 1)
new_topic_name = "edited2"
result = self.client_patch(
f"/json/messages/{id1}",
{
"topic": new_topic_name,
"propagate_mode": "change_later",
},
)
self.assert_json_success(result)
verify_edit_history(new_topic_name, 2)
def test_topic_and_content_edit(self) -> None:
self.login("hamlet")
id1 = self.send_stream_message(self.example_user("hamlet"), "Denmark", "message 1", "topic")
id2 = self.send_stream_message(self.example_user("iago"), "Denmark", "message 2", "topic")
id3 = self.send_stream_message(self.example_user("hamlet"), "Denmark", "message 3", "topic")
new_topic_name = "edited"
result = self.client_patch(
"/json/messages/" + str(id1),
{
"topic": new_topic_name,
"propagate_mode": "change_later",
"content": "edited message",
},
)
self.assert_json_success(result)
# Content change of only id1 should come in edit history
# and topic change should be present in all the messages.
msg1 = Message.objects.get(id=id1)
msg2 = Message.objects.get(id=id2)
msg3 = Message.objects.get(id=id3)
msg1_edit_history = orjson.loads(assert_is_not_none(msg1.edit_history))
self.assertTrue("prev_content" in msg1_edit_history[0])
for msg in [msg2, msg3]:
self.assertFalse(
"prev_content" in orjson.loads(assert_is_not_none(msg.edit_history))[0]
)
for msg in [msg1, msg2, msg3]:
self.assertEqual(
new_topic_name,
msg.topic_name(),
)
self.assert_length(orjson.loads(assert_is_not_none(msg.edit_history)), 1)
def test_propagate_topic_forward(self) -> None:
self.login("hamlet")
id1 = self.send_stream_message(self.example_user("hamlet"), "Denmark", topic_name="topic1")
id2 = self.send_stream_message(self.example_user("iago"), "Denmark", topic_name="topic1")
id3 = self.send_stream_message(self.example_user("iago"), "Verona", topic_name="topic1")
id4 = self.send_stream_message(self.example_user("hamlet"), "Denmark", topic_name="topic2")
id5 = self.send_stream_message(self.example_user("iago"), "Denmark", topic_name="topic1")
result = self.client_patch(
f"/json/messages/{id1}",
{
"topic": "edited",
"propagate_mode": "change_later",
},
)
self.assert_json_success(result)
self.check_topic(id1, topic_name="edited")
self.check_topic(id2, topic_name="edited")
self.check_topic(id3, topic_name="topic1")
self.check_topic(id4, topic_name="topic2")
self.check_topic(id5, topic_name="edited")
def test_propagate_all_topics(self) -> None:
self.login("hamlet")
id1 = self.send_stream_message(self.example_user("hamlet"), "Denmark", topic_name="topic1")
id2 = self.send_stream_message(self.example_user("hamlet"), "Denmark", topic_name="topic1")
id3 = self.send_stream_message(self.example_user("iago"), "Verona", topic_name="topic1")
id4 = self.send_stream_message(self.example_user("hamlet"), "Denmark", topic_name="topic2")
id5 = self.send_stream_message(self.example_user("iago"), "Denmark", topic_name="topic1")
id6 = self.send_stream_message(self.example_user("iago"), "Denmark", topic_name="topic3")
result = self.client_patch(
f"/json/messages/{id2}",
{
"topic": "edited",
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
self.check_topic(id1, topic_name="edited")
self.check_topic(id2, topic_name="edited")
self.check_topic(id3, topic_name="topic1")
self.check_topic(id4, topic_name="topic2")
self.check_topic(id5, topic_name="edited")
self.check_topic(id6, topic_name="topic3")
def test_propagate_all_topics_with_different_uppercase_letters(self) -> None:
self.login("hamlet")
id1 = self.send_stream_message(self.example_user("hamlet"), "Denmark", topic_name="topic1")
id2 = self.send_stream_message(self.example_user("hamlet"), "Denmark", topic_name="Topic1")
id3 = self.send_stream_message(self.example_user("iago"), "Verona", topic_name="topiC1")
id4 = self.send_stream_message(self.example_user("iago"), "Denmark", topic_name="toPic1")
result = self.client_patch(
f"/json/messages/{id2}",
{
"topic": "edited",
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
self.check_topic(id1, topic_name="edited")
self.check_topic(id2, topic_name="edited")
self.check_topic(id3, topic_name="topiC1")
self.check_topic(id4, topic_name="edited")
def test_change_all_propagate_mode_for_moving_from_stream_with_restricted_history(self) -> None:
self.make_stream("privatestream", invite_only=True, history_public_to_subscribers=False)
iago = self.example_user("iago")
cordelia = self.example_user("cordelia")
self.subscribe(iago, "privatestream")
self.subscribe(cordelia, "privatestream")
id1 = self.send_stream_message(iago, "privatestream", topic_name="topic1")
id2 = self.send_stream_message(iago, "privatestream", topic_name="topic1")
hamlet = self.example_user("hamlet")
self.subscribe(hamlet, "privatestream")
id3 = self.send_stream_message(iago, "privatestream", topic_name="topic1")
id4 = self.send_stream_message(hamlet, "privatestream", topic_name="topic1")
self.send_stream_message(hamlet, "privatestream", topic_name="topic1")
message = Message.objects.get(id=id1)
message.date_sent -= timedelta(days=10)
message.save()
message = Message.objects.get(id=id2)
message.date_sent -= timedelta(days=9)
message.save()
message = Message.objects.get(id=id3)
message.date_sent -= timedelta(days=8)
message.save()
message = Message.objects.get(id=id4)
message.date_sent -= timedelta(days=6)
message.save()
self.login("hamlet")
result = self.client_patch(
f"/json/messages/{id4}",
{
"topic": "edited",
"propagate_mode": "change_all",
"send_notification_to_new_thread": "false",
},
)
self.assert_json_error(
result,
"You only have permission to move the 2/3 most recent messages in this topic.",
)
self.login("cordelia")
result = self.client_patch(
f"/json/messages/{id4}",
{
"topic": "edited",
"propagate_mode": "change_all",
"send_notification_to_new_thread": "false",
},
)
self.assert_json_error(
result,
"You only have permission to move the 2/5 most recent messages in this topic.",
)
def test_notify_new_topic(self) -> None:
user_profile = self.example_user("iago")
self.login("iago")
stream = self.make_stream("public stream")
self.subscribe(user_profile, stream.name)
msg_id = self.send_stream_message(
user_profile, stream.name, topic_name="test", content="First"
)
self.send_stream_message(user_profile, stream.name, topic_name="test", content="Second")
self.send_stream_message(user_profile, stream.name, topic_name="test", content="third")
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": "edited",
"propagate_mode": "change_all",
"send_notification_to_old_thread": "false",
"send_notification_to_new_thread": "true",
},
)
self.assert_json_success(result)
messages = get_topic_messages(user_profile, stream, "test")
self.assert_length(messages, 0)
messages = get_topic_messages(user_profile, stream, "edited")
self.assert_length(messages, 4)
self.assertEqual(
messages[3].content,
f"This topic was moved here from #**public stream>test** by @_**Iago|{user_profile.id}**.",
)
def test_notify_old_topic(self) -> None:
user_profile = self.example_user("iago")
self.login("iago")
stream = self.make_stream("public stream")
self.subscribe(user_profile, stream.name)
msg_id = self.send_stream_message(
user_profile, stream.name, topic_name="test", content="First"
)
self.send_stream_message(user_profile, stream.name, topic_name="test", content="Second")
self.send_stream_message(user_profile, stream.name, topic_name="test", content="third")
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": "edited",
"propagate_mode": "change_all",
"send_notification_to_old_thread": "true",
"send_notification_to_new_thread": "false",
},
)
self.assert_json_success(result)
messages = get_topic_messages(user_profile, stream, "test")
self.assert_length(messages, 1)
self.assertEqual(
messages[0].content,
f"This topic was moved to #**public stream>edited** by @_**Iago|{user_profile.id}**.",
)
messages = get_topic_messages(user_profile, stream, "edited")
self.assert_length(messages, 3)
def test_notify_both_topics(self) -> None:
user_profile = self.example_user("iago")
self.login("iago")
stream = self.make_stream("public stream")
self.subscribe(user_profile, stream.name)
msg_id = self.send_stream_message(
user_profile, stream.name, topic_name="test", content="First"
)
self.send_stream_message(user_profile, stream.name, topic_name="test", content="Second")
self.send_stream_message(user_profile, stream.name, topic_name="test", content="third")
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": "edited",
"propagate_mode": "change_all",
"send_notification_to_old_thread": "true",
"send_notification_to_new_thread": "true",
},
)
self.assert_json_success(result)
messages = get_topic_messages(user_profile, stream, "test")
self.assert_length(messages, 1)
self.assertEqual(
messages[0].content,
f"This topic was moved to #**public stream>edited** by @_**Iago|{user_profile.id}**.",
)
messages = get_topic_messages(user_profile, stream, "edited")
self.assert_length(messages, 4)
self.assertEqual(
messages[3].content,
f"This topic was moved here from #**public stream>test** by @_**Iago|{user_profile.id}**.",
)
def test_notify_no_topic(self) -> None:
user_profile = self.example_user("iago")
self.login("iago")
stream = self.make_stream("public stream")
self.subscribe(user_profile, stream.name)
msg_id = self.send_stream_message(
user_profile, stream.name, topic_name="test", content="First"
)
self.send_stream_message(user_profile, stream.name, topic_name="test", content="Second")
self.send_stream_message(user_profile, stream.name, topic_name="test", content="third")
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": "edited",
"propagate_mode": "change_all",
"send_notification_to_old_thread": "false",
"send_notification_to_new_thread": "false",
},
)
self.assert_json_success(result)
messages = get_topic_messages(user_profile, stream, "test")
self.assert_length(messages, 0)
messages = get_topic_messages(user_profile, stream, "edited")
self.assert_length(messages, 3)
def test_notify_old_topics_after_message_move(self) -> None:
user_profile = self.example_user("iago")
self.login("iago")
stream = self.make_stream("public stream")
self.subscribe(user_profile, stream.name)
msg_id = self.send_stream_message(
user_profile, stream.name, topic_name="test", content="First"
)
self.send_stream_message(user_profile, stream.name, topic_name="test", content="Second")
self.send_stream_message(user_profile, stream.name, topic_name="test", content="Third")
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": "edited",
"propagate_mode": "change_one",
"send_notification_to_old_thread": "true",
"send_notification_to_new_thread": "false",
},
)
self.assert_json_success(result)
messages = get_topic_messages(user_profile, stream, "test")
self.assert_length(messages, 3)
self.assertEqual(messages[0].content, "Second")
self.assertEqual(messages[1].content, "Third")
self.assertEqual(
messages[2].content,
f"A message was moved from this topic to #**public stream>edited** by @_**Iago|{user_profile.id}**.",
)
messages = get_topic_messages(user_profile, stream, "edited")
self.assert_length(messages, 1)
self.assertEqual(messages[0].content, "First")
def test_notify_no_topic_after_message_move(self) -> None:
user_profile = self.example_user("iago")
self.login("iago")
stream = self.make_stream("public stream")
self.subscribe(user_profile, stream.name)
msg_id = self.send_stream_message(
user_profile, stream.name, topic_name="test", content="First"
)
self.send_stream_message(user_profile, stream.name, topic_name="test", content="Second")
self.send_stream_message(user_profile, stream.name, topic_name="test", content="Third")
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": "edited",
"propagate_mode": "change_one",
"send_notification_to_old_thread": "false",
"send_notification_to_new_thread": "false",
},
)
self.assert_json_success(result)
messages = get_topic_messages(user_profile, stream, "test")
self.assert_length(messages, 2)
self.assertEqual(messages[0].content, "Second")
self.assertEqual(messages[1].content, "Third")
messages = get_topic_messages(user_profile, stream, "edited")
self.assert_length(messages, 1)
self.assertEqual(messages[0].content, "First")
def test_notify_resolve_topic_long_name(self) -> None:
user_profile = self.example_user("hamlet")
self.login("hamlet")
stream = self.make_stream("public stream")
self.subscribe(user_profile, stream.name)
# Marking topics with a long name as resolved causes the new topic name to be truncated.
# We want to avoid having code paths believing that the topic is "moved" instead of
# "resolved" in this edge case.
topic_name = "a" * MAX_TOPIC_NAME_LENGTH
msg_id = self.send_stream_message(
user_profile, stream.name, topic_name=topic_name, content="First"
)
resolved_topic = RESOLVED_TOPIC_PREFIX + topic_name
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": resolved_topic,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
new_topic_name = truncate_topic(resolved_topic)
messages = get_topic_messages(user_profile, stream, new_topic_name)
self.assert_length(messages, 2)
self.assertEqual(messages[0].content, "First")
self.assertEqual(
messages[1].content,
f"@_**{user_profile.full_name}|{user_profile.id}** has marked this topic as resolved.",
)
message_fetch_result = self.client_get(
f"/json/messages/{msg_id}",
)
self.assert_json_success(message_fetch_result)
message_dict = orjson.loads(message_fetch_result.content)["message"]
self.assert_json_success(result)
self.assertNotIn("last_moved_timestamp", message_dict)
# Note that we are removing the prefix from the already truncated topic,
# so unresolved_topic_name will not be the same as the original topic_name
unresolved_topic_name = new_topic_name.replace(RESOLVED_TOPIC_PREFIX, "")
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": unresolved_topic_name,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
messages = get_topic_messages(user_profile, stream, unresolved_topic_name)
self.assert_length(messages, 3)
self.assertEqual(
messages[2].content,
f"@_**{user_profile.full_name}|{user_profile.id}** has marked this topic as unresolved.",
)
message_fetch_result = self.client_get(
f"/json/messages/{msg_id}",
)
self.assert_json_success(message_fetch_result)
message_dict = orjson.loads(message_fetch_result.content)["message"]
self.assert_json_success(result)
self.assertNotIn("last_moved_timestamp", message_dict)
def test_notify_resolve_and_move_topic(self) -> None:
user_profile = self.example_user("hamlet")
self.login("hamlet")
stream = self.make_stream("public stream")
topic_name = "test"
self.subscribe(user_profile, stream.name)
# Resolve a topic normally first
time_zero = timezone_now().replace(microsecond=0)
with time_machine.travel(time_zero, tick=False):
msg_id = self.send_stream_message(
user_profile, stream.name, "foo", topic_name=topic_name
)
resolved_topic_name = RESOLVED_TOPIC_PREFIX + topic_name
first_message_move_time = time_zero + timedelta(seconds=2)
with time_machine.travel(first_message_move_time, tick=False):
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": resolved_topic_name,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
messages = get_topic_messages(user_profile, stream, resolved_topic_name)
self.assert_length(messages, 2)
self.assertEqual(
messages[1].content,
f"@_**{user_profile.full_name}|{user_profile.id}** has marked this topic as resolved.",
)
message_fetch_result = self.client_get(
f"/json/messages/{msg_id}",
)
self.assert_json_success(message_fetch_result)
message_dict = orjson.loads(message_fetch_result.content)["message"]
self.assert_json_success(result)
self.assertNotIn("last_moved_timestamp", message_dict)
# Test unresolving a topic while moving it (✔ test -> bar)
new_topic_name = "bar"
second_message_move_time = time_zero + timedelta(seconds=4)
with time_machine.travel(second_message_move_time, tick=False):
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": new_topic_name,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
messages = get_topic_messages(user_profile, stream, new_topic_name)
self.assert_length(messages, 3)
self.assertEqual(
messages[2].content,
f"This topic was moved here from #**public stream>✔ test** by @_**{user_profile.full_name}|{user_profile.id}**.",
)
message_fetch_result = self.client_get(
f"/json/messages/{msg_id}",
)
self.assert_json_success(message_fetch_result)
message_dict = orjson.loads(message_fetch_result.content)["message"]
self.assert_json_success(result)
self.assertEqual(
message_dict["last_moved_timestamp"],
datetime_to_timestamp(second_message_move_time),
)
# Now test moving the topic while also resolving it (bar -> ✔ baz)
new_resolved_topic_name = RESOLVED_TOPIC_PREFIX + "baz"
third_message_move_time = time_zero + timedelta(seconds=6)
with time_machine.travel(third_message_move_time, tick=False):
result = self.client_patch(
"/json/messages/" + str(msg_id),
{
"topic": new_resolved_topic_name,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
messages = get_topic_messages(user_profile, stream, new_resolved_topic_name)
self.assert_length(messages, 4)
self.assertEqual(
messages[3].content,
f"This topic was moved here from #**public stream>{new_topic_name}** by @_**{user_profile.full_name}|{user_profile.id}**.",
)
message_fetch_result = self.client_get(
f"/json/messages/{msg_id}",
)
self.assert_json_success(message_fetch_result)
message_dict = orjson.loads(message_fetch_result.content)["message"]
self.assert_json_success(result)
self.assertEqual(
message_dict["last_moved_timestamp"],
datetime_to_timestamp(third_message_move_time),
)
def test_mark_topic_as_resolved(self) -> None:
self.login("iago")
admin_user = self.example_user("iago")
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
aaron = self.example_user("aaron")
# Set the user's translation language to German to test that
# it is overridden by the realm's default language.
admin_user.default_language = "de"
admin_user.save()
stream = self.make_stream("new")
self.subscribe(admin_user, stream.name)
self.subscribe(hamlet, stream.name)
self.subscribe(cordelia, stream.name)
self.subscribe(aaron, stream.name)
original_topic_name = "topic 1"
id1 = self.send_stream_message(hamlet, "new", topic_name=original_topic_name)
id2 = self.send_stream_message(admin_user, "new", topic_name=original_topic_name)
msg1 = Message.objects.get(id=id1)
do_add_reaction(aaron, msg1, "tada", "1f389", "unicode_emoji")
# Check that we don't incorrectly send "unresolve topic"
# notifications when asking the preserve the current topic.
result = self.client_patch(
"/json/messages/" + str(id1),
{
"topic": original_topic_name,
"propagate_mode": "change_all",
},
)
self.assert_json_error(result, "Nothing to change")
resolved_topic_name = RESOLVED_TOPIC_PREFIX + original_topic_name
result = self.resolve_topic_containing_message(
admin_user,
id1,
HTTP_ACCEPT_LANGUAGE="de",
)
self.assert_json_success(result)
for msg_id in [id1, id2]:
msg = Message.objects.get(id=msg_id)
self.assertEqual(
resolved_topic_name,
msg.topic_name(),
)
messages = get_topic_messages(admin_user, stream, resolved_topic_name)
self.assert_length(messages, 3)
self.assertEqual(
messages[2].content,
f"@_**Iago|{admin_user.id}** has marked this topic as resolved.",
)
# Now move to a weird state and confirm we get the normal topic moved message.
weird_topic_name = "✔ ✔✔" + original_topic_name
result = self.client_patch(
"/json/messages/" + str(id1),
{
"topic": weird_topic_name,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
for msg_id in [id1, id2]:
msg = Message.objects.get(id=msg_id)
self.assertEqual(
weird_topic_name,
msg.topic_name(),
)
messages = get_topic_messages(admin_user, stream, weird_topic_name)
self.assert_length(messages, 4)
self.assertEqual(
messages[2].content,
f"@_**Iago|{admin_user.id}** has marked this topic as resolved.",
)
self.assertEqual(
messages[3].content,
f"This topic was moved here from #**new>✔ topic 1** by @_**Iago|{admin_user.id}**.",
)
unresolved_topic_name = original_topic_name
result = self.client_patch(
"/json/messages/" + str(id1),
{
"topic": unresolved_topic_name,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
for msg_id in [id1, id2]:
msg = Message.objects.get(id=msg_id)
self.assertEqual(
unresolved_topic_name,
msg.topic_name(),
)
messages = get_topic_messages(admin_user, stream, unresolved_topic_name)
self.assert_length(messages, 5)
self.assertEqual(
messages[2].content, f"@_**Iago|{admin_user.id}** has marked this topic as resolved."
)
self.assertEqual(
messages[4].content,
f"@_**Iago|{admin_user.id}** has marked this topic as unresolved.",
)
def test_resolved_topic_notice_auto_read_policy(self) -> None:
# Test that resolved and unresolved-topic notices are marked as
# read/unread based on the 'resolved_topic_notice_auto_read_policy'
# setting for followed and unfollowed topics.
self.login("iago")
admin_user = self.example_user("iago")
aaron = self.example_user("aaron")
cordelia = self.example_user("cordelia")
hamlet = self.example_user("hamlet")
do_change_user_setting(
aaron,
"resolved_topic_notice_auto_read_policy",
ResolvedTopicNoticeAutoReadPolicyEnum.always,
acting_user=None,
)
do_change_user_setting(
cordelia,
"resolved_topic_notice_auto_read_policy",
ResolvedTopicNoticeAutoReadPolicyEnum.except_followed,
acting_user=None,
)
do_change_user_setting(
hamlet,
"resolved_topic_notice_auto_read_policy",
ResolvedTopicNoticeAutoReadPolicyEnum.never,
acting_user=None,
)
stream = self.make_stream("stream")
self.subscribe(admin_user, stream.name)
self.subscribe(aaron, stream.name)
self.subscribe(cordelia, stream.name)
self.subscribe(hamlet, stream.name)
followed_topic_name = "followed"
msg_id_1 = self.send_stream_message(admin_user, "stream", topic_name=followed_topic_name)
do_set_user_topic_visibility_policy(
aaron,
stream,
followed_topic_name,
visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED,
)
do_set_user_topic_visibility_policy(
cordelia,
stream,
followed_topic_name,
visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED,
)
do_set_user_topic_visibility_policy(
hamlet,
stream,
followed_topic_name,
visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED,
)
resolved_followed_topic_name = RESOLVED_TOPIC_PREFIX + followed_topic_name
result_1 = self.resolve_topic_containing_message(
admin_user,
msg_id_1,
)
self.assert_json_success(result_1)
msg_1 = Message.objects.get(id=msg_id_1)
self.assertEqual(resolved_followed_topic_name, msg_1.topic_name())
messages = get_topic_messages(admin_user, stream, resolved_followed_topic_name)
self.assert_length(messages, 2)
unread_user_ids = self.get_user_ids_for_whom_message_unread(messages[1].id)
read_user_ids = self.get_user_ids_for_whom_message_read(messages[1].id)
# For the resolved-topic notice in a "followed" topic:
# - aaron: policy 'always' -> notice is marked as read.
# - cordelia: policy 'except_followed' and is following -> notice remains unread.
# - hamlet: policy 'never' -> notice is remains unread.
# - admin_user: is the one who resolved the topic -> notice is marked as read.
self.assertEqual(unread_user_ids, {hamlet.id, cordelia.id})
self.assertEqual(read_user_ids, {aaron.id, admin_user.id})
unfollowed_topic_name = "unfollowed"
msg_id_2 = self.send_stream_message(admin_user, "stream", topic_name=unfollowed_topic_name)
resolved_unfollowed_topic_name = RESOLVED_TOPIC_PREFIX + unfollowed_topic_name
result_2 = self.resolve_topic_containing_message(
admin_user,
msg_id_2,
)
self.assert_json_success(result_2)
msg_2 = Message.objects.get(id=msg_id_2)
self.assertEqual(resolved_unfollowed_topic_name, msg_2.topic_name())
messages = get_topic_messages(admin_user, stream, resolved_unfollowed_topic_name)
self.assert_length(messages, 2)
unread_user_ids = self.get_user_ids_for_whom_message_unread(messages[1].id)
read_user_ids = self.get_user_ids_for_whom_message_read(messages[1].id)
# For the resolved-topic notice in an "unfollowed" topic:
# - aaron: policy 'always' -> notice is marked as read.
# - cordelia: policy 'except_followed' but not following -> notice is marked as read.
# - hamlet: policy 'never' -> notice remains unread.
# - admin_user: is the one who resolved the topic -> notice is marked as read.
self.assertEqual(unread_user_ids, {hamlet.id})
self.assertEqual(read_user_ids, {aaron.id, cordelia.id, admin_user.id})
result_3 = self.client_patch(
"/json/messages/" + str(msg_id_1),
{
"topic": followed_topic_name,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result_3)
msg_3 = Message.objects.get(id=msg_id_1)
self.assertEqual(followed_topic_name, msg_3.topic_name())
messages = get_topic_messages(admin_user, stream, followed_topic_name)
self.assert_length(messages, 3)
unread_user_ids = self.get_user_ids_for_whom_message_unread(messages[2].id)
read_user_ids = self.get_user_ids_for_whom_message_read(messages[2].id)
# Unresolved-topic notices in a "followed" topic
# behave similar to resolved-topic notices.
self.assertEqual(unread_user_ids, {hamlet.id, cordelia.id})
self.assertEqual(read_user_ids, {aaron.id, admin_user.id})
result_4 = self.client_patch(
"/json/messages/" + str(msg_id_2),
{
"topic": unfollowed_topic_name,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result_4)
msg_4 = Message.objects.get(id=msg_id_2)
self.assertEqual(unfollowed_topic_name, msg_4.topic_name())
messages = get_topic_messages(admin_user, stream, unfollowed_topic_name)
self.assert_length(messages, 3)
unread_user_ids = self.get_user_ids_for_whom_message_unread(messages[2].id)
read_user_ids = self.get_user_ids_for_whom_message_read(messages[2].id)
# Unresolved-topic notices in an "unfollowed" topic
# behave similar to resolved-topic notices.
self.assertEqual(unread_user_ids, {hamlet.id})
self.assertEqual(read_user_ids, {aaron.id, cordelia.id, admin_user.id})
do_change_user_setting(
aaron,
"resolved_topic_notice_auto_read_policy",
ResolvedTopicNoticeAutoReadPolicyEnum.never,
acting_user=None,
)
do_change_user_setting(
cordelia,
"resolved_topic_notice_auto_read_policy",
ResolvedTopicNoticeAutoReadPolicyEnum.never,
acting_user=None,
)
do_change_user_setting(
admin_user,
"resolved_topic_notice_auto_read_policy",
ResolvedTopicNoticeAutoReadPolicyEnum.never,
acting_user=None,
)
# Test topic resolved notice is automatically mark as read for the user
# who resolved the topic.
msg_id_5 = self.send_stream_message(admin_user, "stream", topic_name=unfollowed_topic_name)
result_5 = self.resolve_topic_containing_message(
hamlet,
msg_id_5,
)
self.assert_json_success(result_5)
msg_5 = Message.objects.get(id=msg_id_5)
self.assertEqual(resolved_unfollowed_topic_name, msg_5.topic_name())
messages = get_topic_messages(admin_user, stream, resolved_unfollowed_topic_name)
self.assert_length(messages, 5)
unread_user_ids = self.get_user_ids_for_whom_message_unread(messages[-1].id)
read_user_ids = self.get_user_ids_for_whom_message_read(messages[-1].id)
self.assertEqual(read_user_ids, {hamlet.id})
self.assertEqual(unread_user_ids, {aaron.id, cordelia.id, admin_user.id})
def test_move_message_notice_auto_read_for_acting_user(self) -> None:
admin_user = self.example_user("iago")
cordelia = self.example_user("cordelia")
hamlet = self.example_user("hamlet")
stream = self.make_stream("stream", hamlet.realm)
self.subscribe(admin_user, "stream")
self.subscribe(cordelia, "stream")
self.subscribe(hamlet, "stream")
old_topic_name = "old topic"
new_topic_name = "new topic"
message_id = self.send_stream_message(hamlet, stream.name, topic_name=old_topic_name)
result = self.api_patch(
hamlet,
"/api/v1/messages/" + str(message_id),
{"topic": new_topic_name, "send_notification_to_old_thread": "true"},
)
self.assert_json_success(result)
# Messages moved notice should be automatically mark as read for the user
# who move the messages.
old_topic_messages = get_topic_messages(admin_user, stream, old_topic_name)
self.assert_length(old_topic_messages, 1)
self.assertEqual(
old_topic_messages[-1].content,
"This topic was moved to #**stream>new topic** by @_**King Hamlet|10**.",
)
unread_user_ids = self.get_user_ids_for_whom_message_unread(old_topic_messages[-1].id)
read_user_ids = self.get_user_ids_for_whom_message_read(old_topic_messages[-1].id)
self.assertEqual(read_user_ids, {hamlet.id})
self.assertEqual(unread_user_ids, {cordelia.id, admin_user.id})
new_topic_messages = get_topic_messages(admin_user, stream, new_topic_name)
self.assert_length(new_topic_messages, 2)
self.assertEqual(
new_topic_messages[-1].content,
"This topic was moved here from #**stream>old topic** by @_**King Hamlet|10**.",
)
unread_user_ids = self.get_user_ids_for_whom_message_unread(new_topic_messages[-1].id)
read_user_ids = self.get_user_ids_for_whom_message_read(new_topic_messages[-1].id)
self.assertEqual(read_user_ids, {hamlet.id})
self.assertEqual(unread_user_ids, {cordelia.id, admin_user.id})
@override_settings(RESOLVE_TOPIC_UNDO_GRACE_PERIOD_SECONDS=60)
def test_mark_topic_as_resolved_within_grace_period(self) -> None:
self.login("iago")
admin_user = self.example_user("iago")
hamlet = self.example_user("hamlet")
stream = self.make_stream("new")
self.subscribe(admin_user, stream.name)
self.subscribe(hamlet, stream.name)
original_topic = "topic 1"
id1 = self.send_stream_message(
hamlet, "new", content="message 1", topic_name=original_topic
)
id2 = self.send_stream_message(
admin_user, "new", content="message 2", topic_name=original_topic
)
resolved_topic = RESOLVED_TOPIC_PREFIX + original_topic
start_time = timezone_now()
with time_machine.travel(start_time, tick=False):
result = self.client_patch(
"/json/messages/" + str(id1),
{
"topic": resolved_topic,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
for msg_id in [id1, id2]:
msg = Message.objects.get(id=msg_id)
self.assertEqual(
resolved_topic,
msg.topic_name(),
)
messages = get_topic_messages(admin_user, stream, resolved_topic)
self.assert_length(messages, 3)
self.assertEqual(
messages[2].content,
f"@_**Iago|{admin_user.id}** has marked this topic as resolved.",
)
unresolved_topic = original_topic
# Now unresolve the topic within the grace period.
with time_machine.travel(start_time + timedelta(seconds=30), tick=False):
result = self.client_patch(
"/json/messages/" + str(id1),
{
"topic": unresolved_topic,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
for msg_id in [id1, id2]:
msg = Message.objects.get(id=msg_id)
self.assertEqual(
unresolved_topic,
msg.topic_name(),
)
messages = get_topic_messages(admin_user, stream, unresolved_topic)
# The message about the topic having been resolved is gone.
self.assert_length(messages, 2)
self.assertEqual(
messages[1].content,
"message 2",
)
self.assertEqual(messages[0].content, "message 1")
# Now resolve the topic again after the grace period
with time_machine.travel(start_time + timedelta(seconds=61), tick=False):
result = self.client_patch(
"/json/messages/" + str(id1),
{
"topic": resolved_topic,
"propagate_mode": "change_all",
},
)
self.assert_json_success(result)
for msg_id in [id1, id2]:
msg = Message.objects.get(id=msg_id)
self.assertEqual(
resolved_topic,
msg.topic_name(),
)
messages = get_topic_messages(admin_user, stream, resolved_topic)
self.assert_length(messages, 3)
self.assertEqual(
messages[2].content,
f"@_**Iago|{admin_user.id}** has marked this topic as resolved.",
)
def test_send_resolve_topic_notification_with_no_topic_messages(self) -> None:
self.login("iago")
admin_user = self.example_user("iago")
hamlet = self.example_user("hamlet")
stream = self.make_stream("new")
self.subscribe(admin_user, stream.name)
self.subscribe(hamlet, stream.name)
original_topic = "topic 1"
message_id = self.send_stream_message(
hamlet, "new", content="message 1", topic_name=original_topic
)
message = Message.objects.get(id=message_id)
do_delete_messages(admin_user.realm, [message], acting_user=None)
assert stream.recipient_id is not None
resolve_topic = RESOLVED_TOPIC_PREFIX + original_topic
message_edit_request = build_message_edit_request(
message=message,
user_profile=admin_user,
propagate_mode="change_all",
stream_id=None,
topic_name=resolve_topic,
content=None,
)
assert isinstance(message_edit_request, StreamMessageEditRequest)
maybe_send_resolve_topic_notifications(
user_profile=admin_user,
message_edit_request=message_edit_request,
users_following_topic=None,
)
topic_messages = get_topic_messages(admin_user, stream, resolve_topic)
self.assert_length(topic_messages, 1)
self.assertEqual(
topic_messages[0].content,
f"@_**Iago|{admin_user.id}** has marked this topic as resolved.",
)
def test_resolve_empty_string_topic(self) -> None:
hamlet = self.example_user("hamlet")
message_id = self.send_stream_message(hamlet, "Denmark", topic_name="")
result = self.resolve_topic_containing_message(hamlet, target_message_id=message_id)
self.assert_json_error(result, "General chat cannot be marked as resolved")
# Verification for old clients that don't support empty string topic.
message_id = self.send_stream_message(
hamlet, "Denmark", topic_name=Message.EMPTY_TOPIC_FALLBACK_NAME
)
result = self.resolve_topic_containing_message(hamlet, target_message_id=message_id)
self.assert_json_error(result, "General chat cannot be marked as resolved")
def test_resolved_topic_realm_level_permissions(self) -> None:
self.login("iago")
admin_user = self.example_user("iago")
hamlet = self.example_user("hamlet")
othello = self.example_user("othello")
cordelia = self.example_user("cordelia")
admin_user.default_language = "de"
admin_user.save()
stream = self.make_stream("new")
self.subscribe(admin_user, stream.name)
self.subscribe(hamlet, stream.name)
original_topic_name = "topic 1"
id1 = self.send_stream_message(hamlet, stream.name, topic_name=original_topic_name)
# Test resolving topics disabled by organization
nobody_group = NamedUserGroup.objects.get(
name=SystemGroups.NOBODY, realm_for_sharding=admin_user.realm, is_system_group=True
)
do_change_realm_permission_group_setting(
admin_user.realm,
"can_resolve_topics_group",
nobody_group,
acting_user=None,
)
result = self.resolve_topic_containing_message(
admin_user,
id1,
)
self.assert_json_error(
result, "You don't have permission to resolve topics in this channel."
)
# Test restrict resolving topics to admins only.
admins_group = NamedUserGroup.objects.get(
name=SystemGroups.ADMINISTRATORS,
realm_for_sharding=admin_user.realm,
is_system_group=True,
)
do_change_realm_permission_group_setting(
admin_user.realm,
"can_resolve_topics_group",
admins_group,
acting_user=None,
)
result = self.resolve_topic_containing_message(
hamlet,
id1,
)
self.assert_json_error(
result, "You don't have permission to resolve topics in this channel."
)
result = self.resolve_topic_containing_message(
admin_user,
id1,
)
self.assert_json_success(result)
# Test restrict resolving topics to a user defined group.
original_topic_name = "topic 2"
id2 = self.send_stream_message(hamlet, stream.name, topic_name=original_topic_name)
leadership_group = check_add_user_group(
admin_user.realm, "leadership", [hamlet], acting_user=hamlet
)
do_change_realm_permission_group_setting(
admin_user.realm, "can_resolve_topics_group", leadership_group, acting_user=None
)
result = self.resolve_topic_containing_message(
othello,
id2,
)
self.assert_json_error(
result, "You don't have permission to resolve topics in this channel."
)
result = self.resolve_topic_containing_message(
admin_user,
id2,
)
self.assert_json_error(
result, "You don't have permission to resolve topics in this channel."
)
result = self.resolve_topic_containing_message(
hamlet,
id2,
)
self.assert_json_success(result)
# Test restrict topics to an anonymous group.
original_topic_name = "topic 3"
id3 = self.send_stream_message(hamlet, stream.name, topic_name=original_topic_name)
staff_group = check_add_user_group(
admin_user.realm, "Staff", [admin_user], acting_user=admin_user
)
anonymous_setting_group = self.create_or_update_anonymous_group_for_setting(
[cordelia], [staff_group]
)
do_change_realm_permission_group_setting(
admin_user.realm, "can_resolve_topics_group", anonymous_setting_group, acting_user=None
)
result = self.resolve_topic_containing_message(
othello,
id3,
)
self.assert_json_error(
result, "You don't have permission to resolve topics in this channel."
)
result = self.resolve_topic_containing_message(
cordelia,
id3,
)
self.assert_json_success(result)
id3 = self.send_stream_message(hamlet, stream.name, topic_name=original_topic_name)
result = self.resolve_topic_containing_message(
admin_user,
id3,
)
self.assert_json_success(result)
# Test resolving topics when there is no permission to move topics.
do_change_realm_permission_group_setting(
admin_user.realm,
"can_move_messages_between_topics_group",
nobody_group,
acting_user=None,
)
original_topic_name = "topic 4"
id4 = self.send_stream_message(hamlet, stream.name, topic_name=original_topic_name)
# Do not allow if there is some change other than adding
# RESOLVED_TOPIC_PREFIX
self.login_user(cordelia)
result = self.client_patch(
"/json/messages/" + str(id4),
{
"topic": RESOLVED_TOPIC_PREFIX + "topic 45",
"propagate_mode": "change_all",
},
)
self.assert_json_error(result, "You don't have permission to edit this message")
result = self.resolve_topic_containing_message(
cordelia,
id4,
)
self.assert_json_success(result)
# Test resolving topics when time limit for moving messages between
# topics has passed.
do_change_realm_permission_group_setting(
admin_user.realm,
"can_move_messages_between_topics_group",
anonymous_setting_group,
acting_user=None,
)
do_set_realm_property(
admin_user.realm, "move_messages_within_stream_limit_seconds", 3600, acting_user=None
)
original_topic_name = "topic 5"
id5 = self.send_stream_message(hamlet, stream.name, topic_name=original_topic_name)
message = Message.objects.get(id=id5)
message.date_sent -= timedelta(seconds=4000)
message.save()
# Do not allow if there is some change other than adding
# RESOLVED_TOPIC_PREFIX
self.login_user(cordelia)
result = self.client_patch(
"/json/messages/" + str(id5),
{
"topic": RESOLVED_TOPIC_PREFIX + "topic 56",
"propagate_mode": "change_all",
},
)
self.assert_json_error(
result, "The time limit for editing this message's topic has passed."
)
result = self.resolve_topic_containing_message(
cordelia,
id5,
)
self.assert_json_success(result)
def test_resolved_topic_channel_level_permissions(self) -> None:
admin_user = self.example_user("iago")
hamlet = self.example_user("hamlet")
othello = self.example_user("othello")
stream = self.make_stream("new")
self.subscribe(admin_user, stream.name)
self.subscribe(hamlet, stream.name)
# Set resolving topics disabled by organization
nobody_group = NamedUserGroup.objects.get(
name=SystemGroups.NOBODY, realm_for_sharding=admin_user.realm, is_system_group=True
)
do_change_realm_permission_group_setting(
admin_user.realm,
"can_resolve_topics_group",
nobody_group,
acting_user=None,
)
# Test resolving topics disabled in a particular channel.
original_topic_name = "topic 1"
id1 = self.send_stream_message(hamlet, stream.name, topic_name=original_topic_name)
do_change_stream_group_based_setting(
stream, "can_resolve_topics_group", nobody_group, acting_user=admin_user
)
result = self.resolve_topic_containing_message(
admin_user,
id1,
)
self.assert_json_error(
result, "You don't have permission to resolve topics in this channel."
)
# Test restrict resolving topics to admins only in a particular channel.
admins_group = NamedUserGroup.objects.get(
name=SystemGroups.ADMINISTRATORS,
realm_for_sharding=admin_user.realm,
is_system_group=True,
)
do_change_stream_group_based_setting(
stream, "can_resolve_topics_group", admins_group, acting_user=admin_user
)
result = self.resolve_topic_containing_message(
hamlet,
id1,
)
self.assert_json_error(
result, "You don't have permission to resolve topics in this channel."
)
result = self.resolve_topic_containing_message(
admin_user,
id1,
)
self.assert_json_success(result)
# Test restrict resolving topics to a user defined group in a particular channel.
original_topic_name = "topic 2"
id2 = self.send_stream_message(hamlet, stream.name, topic_name=original_topic_name)
leadership_group = check_add_user_group(
admin_user.realm, "leadership", [hamlet], acting_user=hamlet
)
do_change_stream_group_based_setting(
stream, "can_resolve_topics_group", leadership_group, acting_user=admin_user
)
result = self.resolve_topic_containing_message(
othello,
id2,
)
self.assert_json_error(
result, "You don't have permission to resolve topics in this channel."
)
result = self.resolve_topic_containing_message(
admin_user,
id2,
)
self.assert_json_error(
result, "You don't have permission to resolve topics in this channel."
)
result = self.resolve_topic_containing_message(
hamlet,
id2,
)
self.assert_json_success(result)
# Test restrict topics to an anonymous group in a particular channel.
original_topic_name = "topic 3"
id3 = self.send_stream_message(hamlet, stream.name, topic_name=original_topic_name)
othello_group_member_dict = UserGroupMembersData(
direct_members=[othello.id], direct_subgroups=[]
)
do_change_stream_group_based_setting(
stream, "can_resolve_topics_group", othello_group_member_dict, acting_user=othello
)
result = self.resolve_topic_containing_message(
admin_user,
id3,
)
self.assert_json_error(
result, "You don't have permission to resolve topics in this channel."
)
result = self.resolve_topic_containing_message(
othello,
id3,
)
self.assert_json_success(result)
# Test resolving topics while moving topics between different streams.
# User can resolve the topic when moving it between streams if they have
# permission to resolve topics in either the source or destination stream.
original_topic_name = "topic 4"
new_stream = self.make_stream("new stream")
self.subscribe(hamlet, "new stream")
id4 = self.send_stream_message(hamlet, new_stream.name, topic_name=original_topic_name)
do_change_stream_group_based_setting(
new_stream, "can_resolve_topics_group", nobody_group, acting_user=admin_user
)
result = self.api_patch(
admin_user,
"/api/v1/messages/" + str(id4),
{"topic": RESOLVED_TOPIC_PREFIX + original_topic_name, "stream_id": stream.id},
)
self.assert_json_error(
result, "You don't have permission to resolve topics in this channel."
)
# Othello can resolve the topic while moving it since he has permission to resolve topics
# in the destination stream.
result = self.api_patch(
othello,
"/api/v1/messages/" + str(id4),
{"topic": RESOLVED_TOPIC_PREFIX + original_topic_name, "stream_id": stream.id},
)
self.assert_json_success(result)
do_change_realm_permission_group_setting(
admin_user.realm,
"can_move_messages_between_topics_group",
nobody_group,
acting_user=None,
)
original_topic_name = "topic 5"
id5 = self.send_stream_message(hamlet, stream.name, topic_name=original_topic_name)
# Do not allow if there is some change other than adding
# RESOLVED_TOPIC_PREFIX
result = self.api_patch(
othello,
"/api/v1/messages/" + str(id5),
{
"topic": RESOLVED_TOPIC_PREFIX + "topic 45",
"propagate_mode": "change_all",
},
)
self.assert_json_error(result, "You don't have permission to edit this message")
result = self.resolve_topic_containing_message(
othello,
id5,
)
self.assert_json_success(result)
# Test resolving topics when time limit for moving messages between
# topics has passed.
do_change_realm_permission_group_setting(
admin_user.realm,
"can_move_messages_between_topics_group",
leadership_group,
acting_user=None,
)
do_set_realm_property(
admin_user.realm, "move_messages_within_stream_limit_seconds", 3600, acting_user=None
)
do_change_stream_group_based_setting(
stream, "can_resolve_topics_group", leadership_group, acting_user=admin_user
)
original_topic_name = "topic 6"
id6 = self.send_stream_message(hamlet, stream.name, topic_name=original_topic_name)
message = Message.objects.get(id=id6)
message.date_sent -= timedelta(seconds=4000)
message.save()
# Do not allow if there is some change other than adding
# RESOLVED_TOPIC_PREFIX
result = self.api_patch(
hamlet,
"/api/v1/messages/" + str(id6),
{
"topic": RESOLVED_TOPIC_PREFIX + "topic 56",
"propagate_mode": "change_all",
},
)
self.assert_json_error(
result, "The time limit for editing this message's topic has passed."
)
result = self.resolve_topic_containing_message(
hamlet,
id6,
)
self.assert_json_success(result)
def test_can_move_messages_within_channel_group(self) -> None:
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
iago = self.example_user("iago")
realm = hamlet.realm
members_system_group = NamedUserGroup.objects.get(
name=SystemGroups.MEMBERS, realm_for_sharding=realm, is_system_group=True
)
moderators_system_group = NamedUserGroup.objects.get(
name=SystemGroups.MODERATORS, realm_for_sharding=realm, is_system_group=True
)
nobody_system_group = NamedUserGroup.objects.get(
name=SystemGroups.NOBODY, realm_for_sharding=realm, is_system_group=True
)
expected_error = "You don't have permission to edit this message"
do_change_realm_permission_group_setting(
realm,
"can_move_messages_between_topics_group",
nobody_system_group,
acting_user=None,
)
do_change_realm_permission_group_setting(
realm,
"can_move_messages_between_channels_group",
members_system_group,
acting_user=None,
)
stream_1 = get_stream("Denmark", realm)
stream_2 = get_stream("Verona", realm)
# Nobody is allowed to move messages.
self.assert_move_message(
"hamlet", stream_1, topic_name="new topic", expected_error=expected_error
)
# Realm admin can always move messages within the channel.
self.assert_move_message("iago", stream_1, topic_name="new topic")
do_change_stream_group_based_setting(
stream_1,
"can_move_messages_within_channel_group",
members_system_group,
acting_user=iago,
)
# Only members are allowed to move messages within the channel.
self.assert_move_message("hamlet", stream_1, topic_name="new topic")
self.assert_move_message("cordelia", stream_1, topic_name="new topic")
# Guests are not allowed.
self.assert_move_message(
"polonius", stream_1, topic_name="new topic", expected_error=expected_error
)
# Users are allowed to move messages to different topics out of the channel
# if they are in `can_move_messages_within_channel_group` permission of either
# the original channel or the destination channel.
self.assert_move_message(
"hamlet",
stream_1,
stream_id=stream_2.id,
topic_name="new topic",
)
self.assert_move_message(
"hamlet",
stream_2,
stream_id=stream_1.id,
topic_name="new topic",
)
do_change_stream_group_based_setting(
stream_1,
"can_move_messages_within_channel_group",
moderators_system_group,
acting_user=iago,
)
# Hamlet can't move message to another topic now as he is not in
# the `can_move_messages_within_channel_group` of either of the channel.
self.assert_move_message(
"hamlet",
stream_1,
stream_id=stream_2.id,
topic_name="new topic",
expected_error=expected_error,
)
# But he still can move messages between channel without changing the topic.
self.assert_move_message(
"hamlet",
stream_1,
stream_id=stream_2.id,
)
user_group = check_add_user_group(
realm, "new_group", [hamlet, cordelia], acting_user=hamlet
)
do_change_stream_group_based_setting(
stream_1, "can_move_messages_within_channel_group", user_group, acting_user=iago
)
# Hamlet and Cordelia are in the `can_move_messages_within_channel_group`,
# so they can move messages within the channel.
self.assert_move_message("cordelia", stream_1, topic_name="new topic")
self.assert_move_message("hamlet", stream_1, topic_name="new topic")
# But Shiva is not, so he can't.
self.assert_move_message(
"shiva", stream_1, topic_name="new topic", expected_error=expected_error
)
do_change_stream_group_based_setting(
stream_1, "can_administer_channel_group", members_system_group, acting_user=iago
)
# Channel administrators with content access can always move messages within
# the channel even if they are not in `can_move_messages_within_channel_group`.
self.assert_move_message("shiva", stream_1, topic_name="new topic")
def test_move_messages_within_channels_with_updated_topics_policy(self) -> None:
desdemona = self.example_user("desdemona")
realm = desdemona.realm
members_system_group = NamedUserGroup.objects.get(
name=SystemGroups.MEMBERS, realm_for_sharding=realm, is_system_group=True
)
do_change_realm_permission_group_setting(
realm,
"can_move_messages_between_topics_group",
members_system_group,
acting_user=None,
)
stream_1 = get_stream("Denmark", realm)
self.assert_move_message("desdemona", stream_1, topic_name="")
self.assert_move_message("desdemona", stream_1, topic_name="new topic")
do_set_stream_property(
stream_1,
"topics_policy",
StreamTopicsPolicyEnum.disable_empty_topic.value,
acting_user=desdemona,
)
# Cannot move messages to empty topic as `topics_policy` is set to `disable_empty_topic`.
self.assert_move_message(
"desdemona",
stream_1,
topic_name="",
expected_error="Sending messages to the general chat is not allowed in this channel.",
)
self.assert_move_message("desdemona", stream_1, topic_name="new topic")
do_set_stream_property(
stream_1,
"topics_policy",
StreamTopicsPolicyEnum.empty_topic_only.value,
acting_user=desdemona,
)
# Cannot move messages to topics other than empty topic in the channels with
# `topics_policy` set to `empty_topic_only`.
self.assert_move_message(
"desdemona",
stream_1,
orig_topic_name="",
topic_name="new topic",
expected_error="Only the general chat topic is allowed in this channel.",
)