Skip to content

Commit 1166fb0

Browse files
DevasyDelinkelin
andauthored
Add group settings functionality and enhance group details screen (#128)
* feat: Add group settings functionality and enhance group details screen * Refactor screens for improved readability and consistency - Updated import statements to use consistent formatting across GroupDetailsScreen, GroupSettingsScreen, HomeScreen, and JoinGroupScreen. - Changed string literals to use double quotes for consistency. - Simplified API calls by removing unnecessary token parameters in group-related API functions. - Enhanced error handling and user feedback with clearer alert messages. - Improved layout and styling for better user experience in various components. - Refactored settlement status calculations and rendering logic for clarity. * feat: Implement balance checks for group member removal and leaving group * feat: Enhance group settings with image upload functionality and update group icon handling * feat: Improve balance verification on group leave and member removal, update error handling * updates the package_lock.json * feat: Enhance image handling in group settings and home screen * feat: Enhance user profile management with image upload and display in account and friends screens * feat: Normalize user ID shape in AuthContext and FriendsScreen for consistent handling * feat: Enhance FriendsScreen with user images and loading skeletons for improved UI experience * feat: Implement currency formatting utility and update screens to use formatted currency * feat: Enhance API client with retry logic for transient errors and improve image MIME type handling in profile updates * feat: Add tests for group leave and member removal with unsettled balances and exception handling * feat: Simplify empty expenses message display in GroupDetailsScreen --------- Co-authored-by: patel.devasy.23 <[email protected]>
1 parent bff8bd5 commit 1166fb0

20 files changed

+2003
-691
lines changed

backend/app/expenses/service.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -970,17 +970,19 @@ async def get_friends_balance_summary(self, user_id: str) -> Dict[str, Any]:
970970
if member["userId"] != user_id:
971971
friend_ids.add(member["userId"])
972972

973-
# Get user names
973+
# Get user names & images
974974
users = await self.users_collection.find(
975975
{"_id": {"$in": [ObjectId(uid) for uid in friend_ids]}}
976976
).to_list(None)
977977
user_names = {str(user["_id"]): user.get("name", "Unknown") for user in users}
978+
user_images = {str(user["_id"]): user.get("imageUrl") for user in users}
978979

979980
for friend_id in friend_ids:
980981
friend_balance_data = {
981982
"userId": friend_id,
982983
"userName": user_names.get(friend_id, "Unknown"),
983-
"userImageUrl": None, # Would need to be fetched from user profile
984+
# Populate image directly from users collection to avoid extra client round-trips
985+
"userImageUrl": user_images.get(friend_id),
984986
"netBalance": 0,
985987
"owesYou": False,
986988
"breakdown": [],

backend/app/groups/service.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -312,9 +312,29 @@ async def leave_group(self, group_id: str, user_id: str) -> bool:
312312
detail="Cannot leave group when you are the only admin. Delete the group or promote another member to admin first.",
313313
)
314314

315-
# TODO: Check for outstanding balances with expense service
316-
# For now, we'll allow leaving without balance check
317-
# This should be implemented when expense service is ready
315+
# Block leaving when there are unsettled balances involving this user
316+
try:
317+
pending = await db.settlements.find_one(
318+
{
319+
"groupId": group_id, # settlements store string groupId
320+
"status": "pending",
321+
"$or": [{"payerId": user_id}, {"payeeId": user_id}],
322+
},
323+
{"_id": 1},
324+
)
325+
except Exception as e:
326+
logger.error(
327+
f"Failed to verify unsettled balances for group {group_id}: {e}"
328+
)
329+
raise HTTPException(
330+
status_code=503,
331+
detail="Unable to verify unsettled balances right now. Please try again later.",
332+
)
333+
if pending:
334+
raise HTTPException(
335+
status_code=400,
336+
detail="Cannot leave group with unsettled balances. Please settle up first.",
337+
)
318338

319339
result = await db.groups.update_one(
320340
{"_id": obj_id}, {"$pull": {"members": {"userId": user_id}}}
@@ -424,8 +444,29 @@ async def remove_member(self, group_id: str, member_id: str, user_id: str) -> bo
424444
detail="Cannot remove yourself. Use leave group instead",
425445
)
426446

427-
# TODO: Check for outstanding balances with expense service
428-
# For now, we'll allow removal without balance check
447+
# Block removal when there are unsettled balances involving the target member
448+
try:
449+
pending = await db.settlements.find_one(
450+
{
451+
"groupId": group_id, # settlements store string groupId
452+
"status": "pending",
453+
"$or": [{"payerId": member_id}, {"payeeId": member_id}],
454+
},
455+
{"_id": 1},
456+
)
457+
except Exception as e:
458+
logger.error(
459+
f"Failed to verify unsettled balances on removal for group {group_id}: {e}"
460+
)
461+
raise HTTPException(
462+
status_code=503,
463+
detail="Unable to verify unsettled balances right now. Please try again later.",
464+
)
465+
if pending:
466+
raise HTTPException(
467+
status_code=400,
468+
detail="Cannot remove member with unsettled balances. Please settle up first.",
469+
)
429470

430471
result = await db.groups.update_one(
431472
{"_id": obj_id}, {"$pull": {"members": {"userId": member_id}}}

backend/tests/groups/test_groups_service.py

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,125 @@ async def test_join_group_invalid_code(self):
188188
assert exc_info.value.status_code == 404
189189
assert "Invalid join code" in str(exc_info.value.detail)
190190

191+
@pytest.mark.asyncio
192+
async def test_remove_member_blocked_when_unsettled(self):
193+
"""Admin cannot remove member if pending settlements exist"""
194+
mock_db = AsyncMock()
195+
groups = AsyncMock()
196+
settlements = AsyncMock()
197+
mock_db.groups = groups
198+
mock_db.settlements = settlements
199+
200+
group_id = str(ObjectId())
201+
admin_id = "admin123"
202+
member_id = "member456"
203+
204+
groups.find_one.return_value = {
205+
"_id": ObjectId(group_id),
206+
"members": [
207+
{"userId": admin_id, "role": "admin"},
208+
{"userId": member_id, "role": "member"},
209+
],
210+
}
211+
settlements.find_one.return_value = {
212+
"_id": ObjectId(),
213+
"status": "pending",
214+
} # Has pending settlements
215+
216+
with patch.object(self.service, "get_db", return_value=mock_db):
217+
with pytest.raises(HTTPException) as exc:
218+
await self.service.remove_member(group_id, member_id, admin_id)
219+
220+
assert exc.value.status_code == 400
221+
assert "unsettled balances" in str(exc.value.detail)
222+
223+
@pytest.mark.asyncio
224+
async def test_remove_member_allowed_when_settled(self):
225+
"""Admin can remove member when no pending settlements"""
226+
mock_db = AsyncMock()
227+
groups = AsyncMock()
228+
settlements = AsyncMock()
229+
mock_db.groups = groups
230+
mock_db.settlements = settlements
231+
232+
group_id = str(ObjectId())
233+
admin_id = "admin123"
234+
member_id = "member456"
235+
236+
groups.find_one.side_effect = [
237+
{
238+
"_id": ObjectId(group_id),
239+
"members": [
240+
{"userId": admin_id, "role": "admin"},
241+
{"userId": member_id, "role": "member"},
242+
],
243+
}
244+
]
245+
settlements.find_one.return_value = None # No pending settlements
246+
groups.update_one.return_value = MagicMock(modified_count=1)
247+
248+
with patch.object(self.service, "get_db", return_value=mock_db):
249+
ok = await self.service.remove_member(group_id, member_id, admin_id)
250+
251+
assert ok is True
252+
253+
@pytest.mark.asyncio
254+
async def test_leave_group_blocked_when_unsettled(self):
255+
"""Member cannot leave when they have pending settlements"""
256+
mock_db = AsyncMock()
257+
groups = AsyncMock()
258+
settlements = AsyncMock()
259+
mock_db.groups = groups
260+
mock_db.settlements = settlements
261+
262+
group_id = str(ObjectId())
263+
user_id = "user123"
264+
265+
groups.find_one.return_value = {
266+
"_id": ObjectId(group_id),
267+
"members": [
268+
{"userId": user_id, "role": "member"},
269+
{"userId": "other", "role": "admin"},
270+
],
271+
}
272+
settlements.find_one.return_value = {"_id": ObjectId(), "status": "pending"}
273+
274+
with patch.object(self.service, "get_db", return_value=mock_db):
275+
with pytest.raises(HTTPException) as exc:
276+
await self.service.leave_group(group_id, user_id)
277+
278+
assert exc.value.status_code == 400
279+
assert "unsettled balances" in str(exc.value.detail)
280+
281+
@pytest.mark.asyncio
282+
async def test_leave_group_allowed_when_settled(self):
283+
"""Member can leave when no pending settlements and not sole admin"""
284+
mock_db = AsyncMock()
285+
groups = AsyncMock()
286+
settlements = AsyncMock()
287+
mock_db.groups = groups
288+
mock_db.settlements = settlements
289+
290+
group_id = str(ObjectId())
291+
user_id = "user123"
292+
293+
groups.find_one.side_effect = [
294+
{
295+
"_id": ObjectId(group_id),
296+
"members": [
297+
{"userId": user_id, "role": "member"},
298+
{"userId": "admin2", "role": "admin"},
299+
],
300+
}
301+
]
302+
settlements.find_one.return_value = None # No pending settlements
303+
groups.update_one.return_value = MagicMock(modified_count=1)
304+
305+
with patch.object(self.service, "get_db", return_value=mock_db):
306+
ok = await self.service.leave_group(group_id, user_id)
307+
308+
assert ok is True
309+
191310
@pytest.mark.asyncio
192311
async def test_join_group_already_member(self):
193312
"""Test joining group when already a member"""
@@ -411,7 +530,9 @@ async def test_leave_group_allow_member_to_leave(self):
411530
"""Test allowing regular members to leave"""
412531
mock_db = AsyncMock()
413532
mock_collection = AsyncMock()
533+
mock_settlements = AsyncMock()
414534
mock_db.groups = mock_collection
535+
mock_db.settlements = mock_settlements
415536

416537
group = {
417538
"_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"),
@@ -431,6 +552,7 @@ async def test_leave_group_allow_member_to_leave(self):
431552
}
432553

433554
mock_collection.find_one.return_value = group
555+
mock_settlements.find_one.return_value = None # No pending settlements
434556
mock_result = MagicMock()
435557
mock_result.modified_count = 1
436558
mock_collection.update_one.return_value = mock_result
@@ -477,3 +599,144 @@ def test_transform_group_document_partial_input(self):
477599
assert result["name"] == "Partial Group"
478600
assert result["currency"] == "USD" # default fallback
479601
assert result["members"] == [] # default fallback
602+
603+
# --- New tests for unsettled balance checks & exception handling (coverage additions) ---
604+
@pytest.mark.asyncio
605+
async def test_leave_group_pending_settlement_blocks(self):
606+
"""Member can't leave when a pending settlement exists (covers pending branch)."""
607+
mock_db = AsyncMock()
608+
groups = AsyncMock()
609+
settlements = AsyncMock()
610+
mock_db.groups = groups
611+
mock_db.settlements = settlements
612+
613+
group = {
614+
"_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"),
615+
"name": "Test Group",
616+
"members": [
617+
{
618+
"userId": "admin1",
619+
"role": "admin",
620+
"joinedAt": "2023-01-01T00:00:00Z",
621+
},
622+
{
623+
"userId": "member1",
624+
"role": "member",
625+
"joinedAt": "2023-01-01T00:00:00Z",
626+
},
627+
],
628+
}
629+
groups.find_one.return_value = group
630+
settlements.find_one.return_value = {"_id": ObjectId()}
631+
632+
with patch.object(self.service, "get_db", return_value=mock_db):
633+
with pytest.raises(HTTPException) as exc:
634+
await self.service.leave_group(str(group["_id"]), "member1")
635+
636+
assert exc.value.status_code == 400
637+
assert "Cannot leave group with unsettled balances" in exc.value.detail
638+
639+
@pytest.mark.asyncio
640+
async def test_leave_group_settlement_lookup_failure(self):
641+
"""Service returns 503 when settlement lookup errors (covers except block)."""
642+
mock_db = AsyncMock()
643+
groups = AsyncMock()
644+
settlements = AsyncMock()
645+
mock_db.groups = groups
646+
mock_db.settlements = settlements
647+
648+
group = {
649+
"_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"),
650+
"name": "Test Group",
651+
"members": [
652+
{
653+
"userId": "admin1",
654+
"role": "admin",
655+
"joinedAt": "2023-01-01T00:00:00Z",
656+
},
657+
{
658+
"userId": "member1",
659+
"role": "member",
660+
"joinedAt": "2023-01-01T00:00:00Z",
661+
},
662+
],
663+
}
664+
groups.find_one.return_value = group
665+
settlements.find_one.side_effect = Exception("db down")
666+
667+
with patch.object(self.service, "get_db", return_value=mock_db):
668+
with pytest.raises(HTTPException) as exc:
669+
await self.service.leave_group(str(group["_id"]), "member1")
670+
671+
assert exc.value.status_code == 503
672+
assert "Unable to verify unsettled balances" in exc.value.detail
673+
674+
@pytest.mark.asyncio
675+
async def test_remove_member_pending_settlement_blocks(self):
676+
"""Admin can't remove member with pending settlement (covers pending branch)."""
677+
mock_db = AsyncMock()
678+
groups = AsyncMock()
679+
settlements = AsyncMock()
680+
mock_db.groups = groups
681+
mock_db.settlements = settlements
682+
683+
group = {
684+
"_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"),
685+
"name": "Test Group",
686+
"members": [
687+
{
688+
"userId": "admin1",
689+
"role": "admin",
690+
"joinedAt": "2023-01-01T00:00:00Z",
691+
},
692+
{
693+
"userId": "member1",
694+
"role": "member",
695+
"joinedAt": "2023-01-01T00:00:00Z",
696+
},
697+
],
698+
}
699+
groups.find_one.return_value = group # Admin check passes
700+
settlements.find_one.return_value = {"_id": ObjectId()}
701+
702+
with patch.object(self.service, "get_db", return_value=mock_db):
703+
with pytest.raises(HTTPException) as exc:
704+
await self.service.remove_member(str(group["_id"]), "member1", "admin1")
705+
706+
assert exc.value.status_code == 400
707+
assert "Cannot remove member with unsettled balances" in exc.value.detail
708+
709+
@pytest.mark.asyncio
710+
async def test_remove_member_settlement_lookup_failure(self):
711+
"""Service returns 503 when settlement lookup fails during removal (covers except block)."""
712+
mock_db = AsyncMock()
713+
groups = AsyncMock()
714+
settlements = AsyncMock()
715+
mock_db.groups = groups
716+
mock_db.settlements = settlements
717+
718+
group = {
719+
"_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"),
720+
"name": "Test Group",
721+
"members": [
722+
{
723+
"userId": "admin1",
724+
"role": "admin",
725+
"joinedAt": "2023-01-01T00:00:00Z",
726+
},
727+
{
728+
"userId": "member1",
729+
"role": "member",
730+
"joinedAt": "2023-01-01T00:00:00Z",
731+
},
732+
],
733+
}
734+
groups.find_one.return_value = group # Admin check passes
735+
settlements.find_one.side_effect = Exception("db error")
736+
737+
with patch.object(self.service, "get_db", return_value=mock_db):
738+
with pytest.raises(HTTPException) as exc:
739+
await self.service.remove_member(str(group["_id"]), "member1", "admin1")
740+
741+
assert exc.value.status_code == 503
742+
assert "Unable to verify unsettled balances" in exc.value.detail

0 commit comments

Comments
 (0)