@@ -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