Skip to content

Commit 2daddee

Browse files
committed
used transactions and added team name validation
1 parent 2efca08 commit 2daddee

2 files changed

Lines changed: 171 additions & 52 deletions

File tree

src/functions/teams/create/handler.ts

Lines changed: 71 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,40 @@ const teamsCreate: ValidatedEventAPIGatewayProxyEvent<typeof schema> = async (ev
2222
};
2323
}
2424

25+
// Validate team name
26+
const teamName = event.body.team_name.trim();
27+
if (teamName.length === 0) {
28+
return {
29+
statusCode: 400,
30+
body: JSON.stringify({
31+
statusCode: 400,
32+
message: 'Team name cannot be empty',
33+
}),
34+
};
35+
}
36+
37+
if (teamName.length > 50) {
38+
return {
39+
statusCode: 400,
40+
body: JSON.stringify({
41+
statusCode: 400,
42+
message: 'Team name cannot exceed 50 characters',
43+
}),
44+
};
45+
}
46+
47+
// Check for invalid characters (only allow alphanumeric, spaces, hyphens, underscores)
48+
const validNamePattern = /^[a-zA-Z0-9\s\-_]+$/;
49+
if (!validNamePattern.test(teamName)) {
50+
return {
51+
statusCode: 400,
52+
body: JSON.stringify({
53+
statusCode: 400,
54+
message: 'Team name can only contain letters, numbers, spaces, hyphens, and underscores',
55+
}),
56+
};
57+
}
58+
2559
// Connect to database
2660
const db = MongoDB.getInstance(process.env.MONGO_URI);
2761
await db.connect();
@@ -116,55 +150,54 @@ const teamsCreate: ValidatedEventAPIGatewayProxyEvent<typeof schema> = async (ev
116150
leader_email: event.body.auth_email.toLowerCase(),
117151
members: [],
118152
status: 'Active' as const,
119-
team_name: event.body.team_name,
153+
team_name: teamName,
120154
created: new Date(),
121155
updated: new Date(),
122156
};
123157

124-
// Create team
125-
await teams.insertOne(teamDoc);
126-
127-
// Update leader's user document
128-
await users.updateOne(
129-
{ email: event.body.auth_email.toLowerCase() },
130-
{
131-
$set: {
132-
confirmed_team: true,
133-
team_info: {
134-
team_id: teamId,
135-
role: 'leader' as const,
136-
pending_invites: [],
137-
},
138-
},
139-
}
140-
);
141-
142-
// Send invitations to all members via teamInviteLogic utility
158+
// Use MongoDB transaction for atomic team creation
159+
const session = db.getClient().startSession();
160+
143161
try {
144-
const inviteResult = await teamInviteLogic(event.body.auth_email, event.body.auth_token, teamId, memberEmails);
145-
146-
if (inviteResult.statusCode !== 200)
147-
throw new Error(`Team invite failed with status ${inviteResult.statusCode}: ${inviteResult.body}`);
148-
} catch (inviteError) {
149-
console.error('Failed to send team invitations:', inviteError);
150-
151-
// Clean up: delete the team since invitations failed
152-
await teams.deleteOne({ team_id: teamId });
153-
await users.updateOne(
154-
{ email: event.body.auth_email.toLowerCase() },
155-
{
156-
$unset: { team_info: '', confirmed_team: '' },
157-
}
158-
);
159-
162+
await session.withTransaction(async () => {
163+
// Create team within transaction
164+
await teams.insertOne(teamDoc, { session });
165+
166+
// Update leader's user document within transaction
167+
await users.updateOne(
168+
{ email: event.body.auth_email.toLowerCase() },
169+
{
170+
$set: {
171+
confirmed_team: true,
172+
team_info: {
173+
team_id: teamId,
174+
role: 'leader' as const,
175+
pending_invites: [],
176+
},
177+
},
178+
},
179+
{ session }
180+
);
181+
182+
// Send invitations to all members via teamInviteLogic utility
183+
const inviteResult = await teamInviteLogic(event.body.auth_email, event.body.auth_token, teamId, memberEmails);
184+
185+
if (inviteResult.statusCode !== 200)
186+
throw new Error(`Team invite failed with status ${inviteResult.statusCode}: ${inviteResult.body}`);
187+
188+
});
189+
} catch (transactionError) {
190+
console.error('Transaction failed:', transactionError);
160191
return {
161192
statusCode: 500,
162193
body: JSON.stringify({
163194
statusCode: 500,
164-
message: 'Team created but failed to send invitations',
165-
error: inviteError.message,
195+
message: 'Failed to create team',
196+
error: transactionError.message,
166197
}),
167198
};
199+
} finally {
200+
await session.endSession();
168201
}
169202

170203
return {

tests/teams-create.test.ts

Lines changed: 100 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
// External mock objects created before jest.mock()
2+
const mockSession = {
3+
withTransaction: jest.fn(),
4+
endSession: jest.fn(),
5+
};
6+
7+
const mockClient = {
8+
startSession: jest.fn(() => mockSession),
9+
};
10+
211
const mockUsersCollection = {
312
findOne: jest.fn(),
413
updateOne: jest.fn(),
@@ -14,6 +23,7 @@ const mockTeamsCollection = {
1423

1524
const mockDbInstance = {
1625
connect: jest.fn(),
26+
getClient: jest.fn(() => mockClient),
1727
getCollection: jest.fn((name: string) => {
1828
if (name === 'users') return mockUsersCollection;
1929
if (name === 'teams') return mockTeamsCollection;
@@ -49,6 +59,12 @@ describe('Teams Create Handler', () => {
4959
mockTeamsCollection.insertOne.mockResolvedValue({ acknowledged: true });
5060
mockUsersCollection.updateOne.mockResolvedValue({ acknowledged: true });
5161
mockTeamsCollection.deleteOne.mockResolvedValue({ acknowledged: true });
62+
63+
// Mock transaction to execute the callback immediately
64+
mockSession.withTransaction.mockImplementation(async (callback) => {
65+
return await callback();
66+
});
67+
mockSession.endSession.mockResolvedValue(undefined);
5268
});
5369

5470
const mockEventData = {
@@ -92,7 +108,8 @@ describe('Teams Create Handler', () => {
92108
team_name: 'Test Team',
93109
members: [],
94110
status: 'Active',
95-
})
111+
}),
112+
expect.objectContaining({ session: mockSession })
96113
);
97114

98115
// Verify leader update
@@ -106,7 +123,8 @@ describe('Teams Create Handler', () => {
106123
pending_invites: [],
107124
}),
108125
},
109-
}
126+
},
127+
expect.objectContaining({ session: mockSession })
110128
);
111129

112130
// Verify invitations were sent
@@ -219,7 +237,7 @@ describe('Teams Create Handler', () => {
219237
expect(body.message).toBe('Team size cannot exceed 4 members (including leader)');
220238
});
221239

222-
it('should clean up team when invitation sending fails', async () => {
240+
it('should rollback transaction when invitation sending fails', async () => {
223241
// Setup successful validation
224242
mockUsersCollection.findOne
225243
.mockResolvedValueOnce(mockLeaderUser) // Auth user lookup
@@ -237,18 +255,15 @@ describe('Teams Create Handler', () => {
237255

238256
expect(result.statusCode).toBe(500);
239257
const body = JSON.parse(result.body);
240-
expect(body.message).toBe('Team created but failed to send invitations');
258+
expect(body.message).toBe('Failed to create team');
241259

242-
// Verify cleanup: team should be deleted
243-
expect(mockTeamsCollection.deleteOne).toHaveBeenCalledWith(
244-
expect.objectContaining({ team_id: expect.any(String) })
245-
);
260+
// Verify transaction was attempted
261+
expect(mockSession.withTransaction).toHaveBeenCalled();
262+
263+
// Verify session cleanup
264+
expect(mockSession.endSession).toHaveBeenCalled();
246265

247-
// Verify cleanup: leader's team info should be removed
248-
expect(mockUsersCollection.updateOne).toHaveBeenCalledWith(
249-
{ email: 'leader@test.com' },
250-
{ $unset: { team_info: '', confirmed_team: '' } }
251-
);
266+
// No manual cleanup needed since transaction handles rollback automatically
252267
});
253268

254269
it('should handle database errors gracefully', async () => {
@@ -284,7 +299,8 @@ describe('Teams Create Handler', () => {
284299
expect(mockTeamsCollection.insertOne).toHaveBeenCalledWith(
285300
expect.objectContaining({
286301
leader_email: 'leader@test.com', // Should be lowercase
287-
})
302+
}),
303+
expect.objectContaining({ session: mockSession })
288304
);
289305

290306
// Verify invitations are sent with lowercase emails
@@ -295,4 +311,74 @@ describe('Teams Create Handler', () => {
295311
['member1@test.com', 'member2@test.com'] // Should be lowercase
296312
);
297313
});
314+
315+
it('should return 400 for empty team name', async () => {
316+
const emptyNameData = {
317+
...mockEventData,
318+
team_name: ' ', // Just whitespace
319+
};
320+
321+
const mockEvent = createEvent(emptyNameData, '/teams/create', 'POST');
322+
const result = await main(mockEvent, mockContext);
323+
324+
expect(result.statusCode).toBe(400);
325+
const body = JSON.parse(result.body);
326+
expect(body.message).toBe('Team name cannot be empty');
327+
});
328+
329+
it('should return 400 for team name exceeding 50 characters', async () => {
330+
const longNameData = {
331+
...mockEventData,
332+
team_name: 'A'.repeat(51), // 51 characters
333+
};
334+
335+
const mockEvent = createEvent(longNameData, '/teams/create', 'POST');
336+
const result = await main(mockEvent, mockContext);
337+
338+
expect(result.statusCode).toBe(400);
339+
const body = JSON.parse(result.body);
340+
expect(body.message).toBe('Team name cannot exceed 50 characters');
341+
});
342+
343+
it('should return 400 for team name with invalid characters', async () => {
344+
const invalidNameData = {
345+
...mockEventData,
346+
team_name: 'Team@Name!', // Contains @ and !
347+
};
348+
349+
const mockEvent = createEvent(invalidNameData, '/teams/create', 'POST');
350+
const result = await main(mockEvent, mockContext);
351+
352+
expect(result.statusCode).toBe(400);
353+
const body = JSON.parse(result.body);
354+
expect(body.message).toBe('Team name can only contain letters, numbers, spaces, hyphens, and underscores');
355+
});
356+
357+
it('should accept valid team name with allowed characters', async () => {
358+
const validNameData = {
359+
...mockEventData,
360+
team_name: 'Team_Name-123 Valid', // Valid characters
361+
};
362+
363+
// Mock user lookups for validation
364+
mockUsersCollection.findOne
365+
.mockResolvedValueOnce(mockLeaderUser) // Auth user lookup
366+
.mockResolvedValueOnce(mockMemberUser) // member1@test.com lookup
367+
.mockResolvedValueOnce(mockMemberUser); // member2@test.com lookup
368+
369+
const mockEvent = createEvent(validNameData, '/teams/create', 'POST');
370+
const result = await main(mockEvent, mockContext);
371+
372+
expect(result.statusCode).toBe(200);
373+
const body = JSON.parse(result.body);
374+
expect(body.message).toBe('Team created successfully');
375+
376+
// Verify team creation uses trimmed name
377+
expect(mockTeamsCollection.insertOne).toHaveBeenCalledWith(
378+
expect.objectContaining({
379+
team_name: 'Team_Name-123 Valid',
380+
}),
381+
expect.objectContaining({ session: mockSession })
382+
);
383+
});
298384
});

0 commit comments

Comments
 (0)