Repository: couchers-org/couchers
Issue: Leaving a single community makes you leave every community

To reproduce:
- Join more than one community
- Leave one of them
- You are no longer in any community

Changed files:
app/backend/src/couchers/servicers/groups.py, app/backend/src/tests/test_groups.py, app/backend/src/couchers/servicers/communities.py, app/backend/src/tests/test_communities.py

diff --git a/app/backend/src/couchers/servicers/communities.py b/app/backend/src/couchers/servicers/communities.py
index 5b24092d6..b126e01a0 100644
--- a/app/backend/src/couchers/servicers/communities.py
+++ b/app/backend/src/couchers/servicers/communities.py
@@ -287,7 +287,9 @@ class Communities(communities_pb2_grpc.CommunitiesServicer):
             if node.contained_users.filter(User.id == context.user_id).one_or_none():
                 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANNOT_LEAVE_CONTAINING_COMMUNITY)
 
-            session.query(ClusterSubscription).filter(ClusterSubscription.user_id == context.user_id).delete()
+            session.query(ClusterSubscription).filter(
+                ClusterSubscription.cluster_id == node.official_cluster.id
+            ).filter(ClusterSubscription.user_id == context.user_id).delete()
 
             return empty_pb2.Empty()
 
diff --git a/app/backend/src/couchers/servicers/groups.py b/app/backend/src/couchers/servicers/groups.py
index 5f01c283e..4dbc4bb56 100644
--- a/app/backend/src/couchers/servicers/groups.py
+++ b/app/backend/src/couchers/servicers/groups.py
@@ -276,7 +276,9 @@ class Groups(groups_pb2_grpc.GroupsServicer):
             if not user_in_group:
                 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NOT_IN_GROUP)
 
-            session.query(ClusterSubscription).filter(ClusterSubscription.user_id == context.user_id).delete()
+            session.query(ClusterSubscription).filter(ClusterSubscription.cluster_id == request.group_id).filter(
+                ClusterSubscription.user_id == context.user_id
+            ).delete()
 
             return empty_pb2.Empty()
 
diff --git a/app/backend/src/tests/test_communities.py b/app/backend/src/tests/test_communities.py
index b4eb9079f..a4e197a6b 100644
--- a/app/backend/src/tests/test_communities.py
+++ b/app/backend/src/tests/test_communities.py
@@ -786,6 +786,55 @@ def test_node_constraints(db):
     assert "ix_clusters_owner_parent_node_id_is_official_cluster" in str(e.value)
 
 
+def test_LeaveCommunity_regression(db):
+    # See github issue #1444, repro:
+    # 1. Join more than one community
+    # 2. Leave one of them
+    # 3. You are no longer in any community
+    # admin
+    user1, token1 = generate_user(username="user1", geom=create_1d_point(200), geom_radius=0.1)
+    # joiner/leaver
+    user2, token2 = generate_user(username="user2", geom=create_1d_point(201), geom_radius=0.1)
+
+    with session_scope() as session:
+        c0 = create_community(session, 0, 100, "Community 0", [user1], [], None)
+        c1 = create_community(session, 0, 50, "Community 1", [user1], [], c0)
+        c2 = create_community(session, 0, 10, "Community 2", [user1], [], c0)
+        c0_id = c0.id
+        c1_id = c1.id
+        c2_id = c2.id
+
+    enforce_community_memberships()
+
+    with communities_session(token1) as api:
+        assert api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c0_id)).member
+        assert api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c1_id)).member
+        assert api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c2_id)).member
+
+    with communities_session(token2) as api:
+        # first check we're not in any communities
+        assert not api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c0_id)).member
+        assert not api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c1_id)).member
+        assert not api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c2_id)).member
+
+        # join some communities
+        api.JoinCommunity(communities_pb2.JoinCommunityReq(community_id=c1_id))
+        api.JoinCommunity(communities_pb2.JoinCommunityReq(community_id=c2_id))
+
+        # check memberships
+        assert not api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c0_id)).member
+        assert api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c1_id)).member
+        assert api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c2_id)).member
+
+        # leave just c2
+        api.LeaveCommunity(communities_pb2.LeaveCommunityReq(community_id=c2_id))
+
+        # check memberships
+        assert not api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c0_id)).member
+        assert api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c1_id)).member
+        assert not api.GetCommunity(communities_pb2.GetCommunityReq(community_id=c2_id)).member
+
+
 # TODO: requires transferring of content
 
 # def test_ListPlaces(db, testing_communities):
diff --git a/app/backend/src/tests/test_groups.py b/app/backend/src/tests/test_groups.py
index e4d09f6e4..fe3268765 100644
--- a/app/backend/src/tests/test_groups.py
+++ b/app/backend/src/tests/test_groups.py
@@ -3,9 +3,18 @@ import pytest
 
 from couchers import errors
 from couchers.db import session_scope
+from couchers.tasks import enforce_community_memberships
 from proto import groups_pb2, pages_pb2
-from tests.test_communities import get_community_id, get_group_id, get_user_id_and_token, testing_communities  # noqa
-from tests.test_fixtures import groups_session, testconfig  # noqa
+from tests.test_communities import (  # noqa
+    create_1d_point,
+    create_community,
+    create_group,
+    get_community_id,
+    get_group_id,
+    get_user_id_and_token,
+    testing_communities,
+)
+from tests.test_fixtures import db, generate_user, groups_session, testconfig  # noqa
 
 
 @pytest.fixture(autouse=True)
@@ -329,6 +338,48 @@ def test_JoinGroup_and_LeaveGroup(testing_communities):
         assert not api.GetGroup(groups_pb2.GetGroupReq(group_id=h_id)).member
 
 
+def test_LeaveGroup_regression(db):
+    # see test_LeaveCommunity_regression
+
+    # admin
+    user1, token1 = generate_user(username="user1", geom=create_1d_point(200), geom_radius=0.1)
+    # joiner/leaver
+    user2, token2 = generate_user(username="user2", geom=create_1d_point(201), geom_radius=0.1)
+
+    with session_scope() as session:
+        c0 = create_community(session, 0, 100, "Community 0", [user1], [], None)
+        g1 = create_group(session, "Group 1", [user1], [], c0)
+        g2 = create_group(session, "Group 2", [user1], [], c0)
+        g1_id = g1.id
+        g2_id = g2.id
+
+    enforce_community_memberships()
+
+    with groups_session(token1) as api:
+        assert api.GetGroup(groups_pb2.GetGroupReq(group_id=g1_id)).member
+        assert api.GetGroup(groups_pb2.GetGroupReq(group_id=g2_id)).member
+
+    with groups_session(token2) as api:
+        # first check we're not in any groups
+        assert not api.GetGroup(groups_pb2.GetGroupReq(group_id=g1_id)).member
+        assert not api.GetGroup(groups_pb2.GetGroupReq(group_id=g2_id)).member
+
+        # join some groups
+        api.JoinGroup(groups_pb2.JoinGroupReq(group_id=g1_id))
+        api.JoinGroup(groups_pb2.JoinGroupReq(group_id=g2_id))
+
+        # check memberships
+        assert api.GetGroup(groups_pb2.GetGroupReq(group_id=g1_id)).member
+        assert api.GetGroup(groups_pb2.GetGroupReq(group_id=g2_id)).member
+
+        # leave just g2
+        api.LeaveGroup(groups_pb2.LeaveGroupReq(group_id=g2_id))
+
+        # check memberships
+        assert api.GetGroup(groups_pb2.GetGroupReq(group_id=g1_id)).member
+        assert not api.GetGroup(groups_pb2.GetGroupReq(group_id=g2_id)).member
+
+
 # TODO: also requires implementing content transfer functionality
 # Note: allegedly groups cannot contain content other than discussions!