Skip to content

Commit ca38e84

Browse files
Improve exception handling and unit tests for auth, user, groups, expenses (#79)
* Improve exception handling and unit tests for auth, user, groups and expenses services * Slight modification by adding centralized logger to user and group service.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fixed errors, missing imports, and test coverage up to 74% for app * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent e3d170a commit ca38e84

File tree

9 files changed

+1393
-162
lines changed

9 files changed

+1393
-162
lines changed

backend/app/auth/service.py

Lines changed: 228 additions & 88 deletions
Large diffs are not rendered by default.

backend/app/expenses/service.py

Lines changed: 115 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import asyncio
2-
from collections import defaultdict, deque
1+
from collections import defaultdict
32
from datetime import datetime, timedelta
4-
from typing import Any, Dict, List, Optional, Tuple
3+
from typing import Any, Dict, List, Optional
54

65
from app.config import logger
76
from app.database import mongodb
@@ -15,7 +14,8 @@
1514
SettlementStatus,
1615
SplitType,
1716
)
18-
from bson import ObjectId
17+
from bson import ObjectId, errors
18+
from fastapi import HTTPException
1919

2020

2121
class ExpenseService:
@@ -46,15 +46,22 @@ async def create_expense(
4646
# Validate and convert group_id to ObjectId
4747
try:
4848
group_obj_id = ObjectId(group_id)
49-
except Exception:
50-
raise ValueError("Group not found or user not a member")
49+
except errors.InvalidId: # Incorrect ObjectId format
50+
logger.warning(f"Invalid group ID format: {group_id}")
51+
raise HTTPException(status_code=400, detail="Invalid group ID")
52+
except Exception as e:
53+
logger.error(f"Unexpected error parsing groupId: {e}")
54+
raise HTTPException(
55+
status_code=500, detail="Failed to process group ID")
5156

5257
# Verify user is member of the group
5358
group = await self.groups_collection.find_one(
5459
{"_id": group_obj_id, "members.userId": user_id}
5560
)
56-
if not group:
57-
raise ValueError("Group not found or user not a member")
61+
if not group: # User not a member of the group
62+
raise HTTPException(
63+
status_code=403, detail="You are not a member of this group"
64+
)
5865

5966
# Create expense document
6067
expense_doc = {
@@ -231,21 +238,32 @@ async def get_expense_by_id(
231238
try:
232239
group_obj_id = ObjectId(group_id)
233240
expense_obj_id = ObjectId(expense_id)
234-
except Exception:
235-
raise ValueError("Group not found or user not a member")
241+
except errors.InvalidId: # Incorrect ObjectId format for group_id or expense_id
242+
logger.warning(
243+
f"Invalid ObjectId(s): group_id={group_id}, expense_id={expense_id}"
244+
)
245+
raise HTTPException(
246+
status_code=400, detail="Invalid group ID or expense ID"
247+
)
248+
except Exception as e:
249+
logger.error(f"Unexpected error parsing IDs: {e}")
250+
raise HTTPException(
251+
status_code=500, detail="Unable to process IDs")
236252

237253
# Verify user access
238254
group = await self.groups_collection.find_one(
239255
{"_id": group_obj_id, "members.userId": user_id}
240256
)
241-
if not group:
242-
raise ValueError("Group not found or user not a member")
257+
if not group: # Unauthorized access
258+
raise HTTPException(
259+
status_code=403, detail="You are not a member of this group"
260+
)
243261

244262
expense_doc = await self.expenses_collection.find_one(
245263
{"_id": expense_obj_id, "groupId": group_id}
246264
)
247-
if not expense_doc:
248-
raise ValueError("Expense not found")
265+
if not expense_doc: # Expense not found
266+
raise HTTPException(status_code=404, detail="Expense not found")
249267

250268
expense = await self._expense_doc_to_response(expense_doc)
251269

@@ -274,30 +292,39 @@ async def update_expense(
274292
# Validate ObjectId format
275293
try:
276294
expense_obj_id = ObjectId(expense_id)
277-
except Exception as e:
278-
raise ValueError(f"Invalid expense ID format: {expense_id}")
295+
except errors.InvalidId:
296+
logger.warning(f"Invalid expense ID format: {expense_id}")
297+
raise HTTPException(
298+
status_code=400, detail="Invalid expense ID format")
279299

280300
# Verify user access and that they created the expense
281301
expense_doc = await self.expenses_collection.find_one(
282302
{"_id": expense_obj_id, "groupId": group_id, "createdBy": user_id}
283303
)
284-
if not expense_doc:
285-
raise ValueError("Expense not found or not authorized to edit")
304+
if not expense_doc: # Expense not found or user not authorized
305+
raise HTTPException(
306+
status_code=403,
307+
detail="Not authorized to update this expense or it does not exist",
308+
)
286309

287310
# Validate splits against current or new amount if both are being updated
288311
if updates.splits is not None and updates.amount is not None:
289312
total_split = sum(split.amount for split in updates.splits)
290313
if abs(total_split - updates.amount) > 0.01:
291-
raise ValueError(
292-
"Split amounts must sum to total expense amount")
314+
raise HTTPException(
315+
status_code=400,
316+
detail="Split amounts must sum to total expense amount",
317+
)
293318

294319
# If only splits are being updated, validate against current amount
295320
elif updates.splits is not None:
296321
current_amount = expense_doc["amount"]
297322
total_split = sum(split.amount for split in updates.splits)
298323
if abs(total_split - current_amount) > 0.01:
299-
raise ValueError(
300-
"Split amounts must sum to current expense amount")
324+
raise HTTPException(
325+
status_code=400,
326+
detail="Split amounts must sum to total expense amount",
327+
)
301328

302329
# Store original data for history
303330
original_data = {
@@ -332,7 +359,8 @@ async def update_expense(
332359
user.get(
333360
"name", "Unknown User") if user else "Unknown User"
334361
)
335-
except:
362+
except Exception as e:
363+
logger.warning(f"Failed to fetch user for history: {e}")
336364
user_name = "Unknown User"
337365

338366
history_entry = {
@@ -349,16 +377,20 @@ async def update_expense(
349377
{"$set": update_doc, "$push": {"history": history_entry}},
350378
)
351379

352-
if result.matched_count == 0:
353-
raise ValueError("Expense not found during update")
380+
if result.matched_count == 0: # Expense not found during update
381+
raise HTTPException(
382+
status_code=404, detail="Expense not found during update"
383+
)
354384
else:
355385
# No actual changes, just update the timestamp
356386
result = await self.expenses_collection.update_one(
357387
{"_id": expense_obj_id}, {"$set": update_doc}
358388
)
359389

360390
if result.matched_count == 0:
361-
raise ValueError("Expense not found during update")
391+
raise HTTPException(
392+
status_code=404, detail="Expense not found during update"
393+
)
362394

363395
# If splits changed, recalculate settlements
364396
if updates.splits is not None or updates.amount is not None:
@@ -378,10 +410,9 @@ async def update_expense(
378410
await self._create_settlements_for_expense(
379411
updated_expense, user_id
380412
)
381-
except Exception as e:
413+
except Exception:
382414
logger.error(
383-
f"Warning: Failed to recalculate settlements: {e}",
384-
exc_info=True,
415+
f"Warning: Failed to recalculate settlements", exc_info=True
385416
)
386417
# Continue anyway, as the expense update succeeded
387418

@@ -390,14 +421,26 @@ async def update_expense(
390421
{"_id": expense_obj_id}
391422
)
392423
if not updated_expense:
393-
raise ValueError("Failed to retrieve updated expense")
424+
raise HTTPException(
425+
status_code=500, detail="Failed to retrieve updated expense"
426+
)
394427

395428
return await self._expense_doc_to_response(updated_expense)
396429

397-
except ValueError:
430+
# Allowing FastAPI exception to bubble up for proper handling
431+
except HTTPException:
398432
raise
399-
except Exception as e:
400-
logger.error(f"Error in update_expense: {str(e)}", exc_info=True)
433+
except ValueError as ve:
434+
raise HTTPException(status_code=400, detail=str(ve))
435+
except (
436+
Exception
437+
) as e: # logger.exception() will provide the entire traceback, so its safe to remove traceback
438+
logger.exception(
439+
f"Unhandled error in update_expense for expense {expense_id}: {e}"
440+
)
441+
import traceback
442+
443+
traceback.print_exc()
401444
raise Exception(f"Database error during expense update: {str(e)}")
402445

403446
async def delete_expense(
@@ -411,7 +454,13 @@ async def delete_expense(
411454
"createdBy": user_id}
412455
)
413456
if not expense_doc:
414-
raise ValueError("Expense not found or not authorized to delete")
457+
logger.warning(
458+
f"Unauthorized delete attempt or missing expense: {expense_id} by user {user_id}"
459+
)
460+
raise HTTPException(
461+
status_code=403,
462+
detail="Not authorized to delete this expense or it does not exist",
463+
)
415464

416465
# Delete settlements for this expense
417466
await self.settlements_collection.delete_many({"expenseId": expense_id})
@@ -576,7 +625,12 @@ async def create_manual_settlement(
576625
{"_id": ObjectId(group_id), "members.userId": user_id}
577626
)
578627
if not group:
579-
raise ValueError("Group not found or user not a member")
628+
logger.warning(
629+
f"Unauthorized access attempt to group {group_id} by user {user_id}"
630+
)
631+
raise HTTPException(
632+
status_code=403, detail="Group not found or user not a member"
633+
)
580634

581635
# Get user names
582636
users = await self.users_collection.find(
@@ -666,7 +720,12 @@ async def get_group_settlements(
666720
{"_id": ObjectId(group_id), "members.userId": user_id}
667721
)
668722
if not group:
669-
raise ValueError("Group not found or user not a member")
723+
logger.warning(
724+
f"Unauthorized access attempt to group {group_id} by user {user_id}"
725+
)
726+
raise HTTPException(
727+
status_code=403, detail="Group not found or user not a member"
728+
)
670729

671730
# Build query
672731
query = {"groupId": group_id}
@@ -705,17 +764,24 @@ async def get_settlement_by_id(
705764

706765
# Verify user access
707766
group = await self.groups_collection.find_one(
708-
{"_id": ObjectId(group_id), "members.userId": user_id}
767+
{
768+
"_id": ObjectId(
769+
group_id
770+
), # Assuming valid object ID format (same as above functions)
771+
"members.userId": user_id,
772+
}
709773
)
710774
if not group:
711-
raise ValueError("Group not found or user not a member")
775+
raise HTTPException(
776+
status_code=403, detail="Group not found or user not a member"
777+
)
712778

713779
settlement_doc = await self.settlements_collection.find_one(
714780
{"_id": ObjectId(settlement_id), "groupId": group_id}
715781
)
716782

717783
if not settlement_doc:
718-
raise ValueError("Settlement not found")
784+
raise HTTPException(status_code=404, detail="Settlement not found")
719785

720786
return Settlement(**{**settlement_doc, "_id": str(settlement_doc["_id"])})
721787

@@ -740,7 +806,7 @@ async def update_settlement_status(
740806
)
741807

742808
if result.matched_count == 0:
743-
raise ValueError("Settlement not found")
809+
raise HTTPException(status_code=404, detail="Settlement not found")
744810

745811
# Get updated settlement
746812
settlement_doc = await self.settlements_collection.find_one(
@@ -759,7 +825,9 @@ async def delete_settlement(
759825
{"_id": ObjectId(group_id), "members.userId": user_id}
760826
)
761827
if not group:
762-
raise ValueError("Group not found or user not a member")
828+
raise HTTPException(
829+
status_code=403, detail="Group not found or user not a member"
830+
)
763831

764832
result = await self.settlements_collection.delete_one(
765833
{"_id": ObjectId(settlement_id), "groupId": group_id}
@@ -777,7 +845,9 @@ async def get_user_balance_in_group(
777845
{"_id": ObjectId(group_id), "members.userId": current_user_id}
778846
)
779847
if not group:
780-
raise ValueError("Group not found or user not a member")
848+
raise HTTPException(
849+
status_code=403, detail="Group not found or user not a member"
850+
)
781851

782852
# Get user info
783853
user = await self.users_collection.find_one({"_id": ObjectId(target_user_id)})
@@ -1109,7 +1179,9 @@ async def get_group_analytics(
11091179
{"_id": ObjectId(group_id), "members.userId": user_id}
11101180
)
11111181
if not group:
1112-
raise ValueError("Group not found or user not a member")
1182+
raise HTTPException(
1183+
status_code=403, detail="Group not found or user not a member"
1184+
)
11131185

11141186
# Build date range
11151187
if period == "month" and year and month:

0 commit comments

Comments
 (0)