Skip to content

Commit 3c15d6e

Browse files
committed
second attempt at handling double click accept/decline
1 parent c730055 commit 3c15d6e

File tree

6 files changed

+95
-56
lines changed

6 files changed

+95
-56
lines changed

CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ The project uses path aliases (configured in `tsconfig.json`):
109109
- `@utils/log` is mocked globally via `moduleNameMapper`
110110
- Timezone set to `America/Chicago` for tests
111111

112+
## Development Workflow
113+
114+
**IMPORTANT**: Before considering any task complete, ALWAYS run `pnpm verify` to ensure:
115+
116+
- Linting passes
117+
- Code formatting is correct
118+
- TypeScript compilation succeeds
119+
- Build completes successfully
120+
- All tests pass
121+
122+
This helps catch issues early and maintains code quality.
123+
112124
## Commit Conventions
113125

114126
This repo uses [Conventional Commits](https://www.conventionalcommits.org/):

src/bot/__tests__/acceptReviewRequest.test.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,9 @@ jest.mock('@/services/HackParserService', () => ({
6363

6464
describe('acceptReviewRequest', () => {
6565
beforeEach(() => {
66-
activeReviewRepo.getReviewByThreadIdOrFail = jest.fn(
67-
async () =>
68-
({
69-
pdfIdentifier: 'example.pdf',
70-
acceptedReviewers: [],
71-
declinedReviewers: [],
72-
}) as unknown as ActiveReview,
73-
);
66+
// Reset the mock to default implementation
67+
// Note: Individual tests can override this mock as needed
68+
activeReviewRepo.getReviewByThreadIdOrFail = jest.fn();
7469
});
7570

7671
const expectedHackParserPDFBlock = {
@@ -129,6 +124,14 @@ describe('acceptReviewRequest', () => {
129124
];
130125
action.body.message!.ts = '1234';
131126

127+
// Mock review with user in pending list
128+
(activeReviewRepo.getReviewByThreadIdOrFail as jest.Mock).mockResolvedValue({
129+
pdfIdentifier: 'example.pdf',
130+
acceptedReviewers: [],
131+
declinedReviewers: [],
132+
pendingReviewers: [{ userId: action.body.user.id }],
133+
} as unknown as ActiveReview);
134+
132135
userRepo.markNowAsLastReviewedDate = resolve();
133136
chatService.replyToReviewThread = resolve();
134137
chatService.updateDirectMessage = resolve();
@@ -202,25 +205,25 @@ describe('acceptReviewRequest', () => {
202205
},
203206
];
204207

205-
(activeReviewRepo.getReviewByThreadIdOrFail as jest.Mock).mockResolvedValueOnce({
208+
// Mock review where user already accepted (not in pending)
209+
(activeReviewRepo.getReviewByThreadIdOrFail as jest.Mock).mockResolvedValue({
206210
pdfIdentifier: 'example.pdf',
207211
acceptedReviewers: [{ userId }],
208212
declinedReviewers: [],
213+
pendingReviewers: [],
209214
} as unknown as ActiveReview);
210215

211216
const mockUpdateDirectMessage = jest.fn().mockReturnValue(() => Promise.resolve());
212-
const mockAddUser = jest.fn().mockReturnValue(() => Promise.resolve());
213217
const mockMarkNow = jest.fn().mockReturnValue(() => Promise.resolve());
214218

215219
chatService.updateDirectMessage = mockUpdateDirectMessage;
216-
(addUserToAcceptedReviewers as jest.Mock).mockImplementation(mockAddUser);
217220
userRepo.markNowAsLastReviewedDate = mockMarkNow;
218221

219222
const app = buildMockApp();
220223
acceptReviewRequest.setup(app);
221224
await acceptReviewRequest.handleAccept(action);
222225

223-
expect(mockAddUser).not.toHaveBeenCalled();
226+
expect(addUserToAcceptedReviewers).not.toHaveBeenCalled();
224227
expect(mockMarkNow).not.toHaveBeenCalled();
225228
expect(mockUpdateDirectMessage).not.toHaveBeenCalled();
226229
});
@@ -242,25 +245,25 @@ describe('acceptReviewRequest', () => {
242245
},
243246
];
244247

245-
(activeReviewRepo.getReviewByThreadIdOrFail as jest.Mock).mockResolvedValueOnce({
248+
// Mock review where user already declined (not in pending)
249+
(activeReviewRepo.getReviewByThreadIdOrFail as jest.Mock).mockResolvedValue({
246250
pdfIdentifier: 'example.pdf',
247251
acceptedReviewers: [],
248252
declinedReviewers: [{ userId }],
253+
pendingReviewers: [],
249254
} as unknown as ActiveReview);
250255

251256
const mockUpdateDirectMessage = jest.fn().mockReturnValue(() => Promise.resolve());
252-
const mockAddUser = jest.fn().mockReturnValue(() => Promise.resolve());
253257
const mockMarkNow = jest.fn().mockReturnValue(() => Promise.resolve());
254258

255259
chatService.updateDirectMessage = mockUpdateDirectMessage;
256-
(addUserToAcceptedReviewers as jest.Mock).mockImplementation(mockAddUser);
257260
userRepo.markNowAsLastReviewedDate = mockMarkNow;
258261

259262
const app = buildMockApp();
260263
acceptReviewRequest.setup(app);
261264
await acceptReviewRequest.handleAccept(action);
262265

263-
expect(mockAddUser).not.toHaveBeenCalled();
266+
expect(addUserToAcceptedReviewers).not.toHaveBeenCalled();
264267
expect(mockMarkNow).not.toHaveBeenCalled();
265268
expect(mockUpdateDirectMessage).not.toHaveBeenCalled();
266269
});
@@ -278,10 +281,11 @@ describe('acceptReviewRequest', () => {
278281
});
279282

280283
it('should work where there is no PDF identifier', async () => {
281-
(activeReviewRepo.getReviewByThreadIdOrFail as jest.Mock).mockResolvedValueOnce({
284+
(activeReviewRepo.getReviewByThreadIdOrFail as jest.Mock).mockResolvedValue({
282285
pdfIdentifier: '',
283286
acceptedReviewers: [],
284287
declinedReviewers: [],
288+
pendingReviewers: [{ userId: 'test-user-id' }],
285289
} as unknown as ActiveReview);
286290

287291
const { action } = await callHandleAccept();

src/bot/__tests__/declineReviewRequest.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ describe('declineReviewRequest', () => {
4343
const activeReview = {
4444
acceptedReviewers: [],
4545
declinedReviewers: [],
46+
pendingReviewers: [{ userId: action.body.user.id }],
4647
};
4748

4849
activeReviewRepo.getReviewByThreadIdOrFail = jest.fn().mockResolvedValueOnce(activeReview);
@@ -78,6 +79,7 @@ describe('declineReviewRequest', () => {
7879
const activeReview = {
7980
acceptedReviewers: [{ userId }],
8081
declinedReviewers: [],
82+
pendingReviewers: [], // User already accepted, so not in pending
8183
};
8284

8385
activeReviewRepo.getReviewByThreadIdOrFail = jest.fn().mockResolvedValueOnce(activeReview);
@@ -87,8 +89,8 @@ describe('declineReviewRequest', () => {
8789
declineReviewRequest.setup(app);
8890
await declineReviewRequest.handleDecline(action);
8991

92+
// Should not call decline because user is not in pending list
9093
expect(RequestService.declineRequest).not.toHaveBeenCalled();
91-
expect(chatService.updateDirectMessage).not.toHaveBeenCalled();
9294
});
9395

9496
it('should ignore duplicate decline clicks when user already declined', async () => {
@@ -112,6 +114,7 @@ describe('declineReviewRequest', () => {
112114
const activeReview = {
113115
acceptedReviewers: [],
114116
declinedReviewers: [{ userId }],
117+
pendingReviewers: [], // User already declined, so not in pending
115118
};
116119

117120
activeReviewRepo.getReviewByThreadIdOrFail = jest.fn().mockResolvedValueOnce(activeReview);
@@ -121,8 +124,8 @@ describe('declineReviewRequest', () => {
121124
declineReviewRequest.setup(app);
122125
await declineReviewRequest.handleDecline(action);
123126

127+
// Should not call decline because user is not in pending list
124128
expect(RequestService.declineRequest).not.toHaveBeenCalled();
125-
expect(chatService.updateDirectMessage).not.toHaveBeenCalled();
126129
});
127130
});
128131
});

src/bot/acceptReviewRequest.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,30 @@ export const acceptReviewRequest = {
3838

3939
log.d('acceptReviewRequest.handleAccept', `${user.name} accepted review ${threadId}`);
4040

41-
// Idempotency check: If user already responded to this review, ignore duplicate clicks
41+
// Quick check: If user already responded, ignore duplicate clicks
4242
const existingReview = await activeReviewRepo.getReviewByThreadIdOrFail(threadId);
43-
const alreadyAccepted = existingReview.acceptedReviewers.some(r => r.userId === user.id);
44-
const alreadyDeclined = existingReview.declinedReviewers.some(r => r.userId === user.id);
43+
const isPending = existingReview.pendingReviewers.some(r => r.userId === user.id);
4544

46-
if (alreadyAccepted || alreadyDeclined) {
45+
if (!isPending) {
4746
log.d(
4847
'acceptReviewRequest.handleAccept',
4948
`User ${user.id} already responded to review ${threadId}, ignoring duplicate click`,
5049
);
5150
return;
5251
}
5352

53+
// Try to add user to accepted reviewers - this will throw if they're not in pending list
54+
// (race condition protection in case of simultaneous clicks)
55+
try {
56+
await addUserToAcceptedReviewers(user.id, threadId);
57+
} catch (err) {
58+
log.d(
59+
'acceptReviewRequest.handleAccept',
60+
`User ${user.id} already responded to review ${threadId} (race condition), ignoring duplicate click`,
61+
);
62+
return;
63+
}
64+
5465
// remove accept/decline buttons from original message and update it
5566
const blocks = blockUtils.removeBlock(body, BlockId.REVIEWER_DM_BUTTONS);
5667
blocks.push(textBlock('You accepted this review.'));
@@ -82,8 +93,6 @@ export const acceptReviewRequest = {
8293

8394
await chatService.updateDirectMessage(client, user.id, body.message.ts, blocks);
8495

85-
await addUserToAcceptedReviewers(user.id, threadId);
86-
8796
await userRepo.markNowAsLastReviewedDate(user.id);
8897

8998
await chatService.replyToReviewThread(

src/bot/declineReviewRequest.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,30 @@ export const declineReviewRequest = {
2222
const user = body.user;
2323
const threadId = body.actions[0].value;
2424

25-
const review = await activeReviewRepo.getReviewByThreadIdOrFail(threadId);
26-
2725
log.d('declineReviewRequest.handleDecline', `${user.name} declined review ${threadId}`);
2826

29-
// Idempotency check: If user already responded to this review, ignore duplicate clicks
30-
const alreadyAccepted = review.acceptedReviewers.some(r => r.userId === user.id);
31-
const alreadyDeclined = review.declinedReviewers.some(r => r.userId === user.id);
27+
const review = await activeReviewRepo.getReviewByThreadIdOrFail(threadId);
3228

33-
if (alreadyAccepted || alreadyDeclined) {
29+
// Check if user is in pending list - if not, they already responded
30+
const isPending = review.pendingReviewers.some(r => r.userId === user.id);
31+
if (!isPending) {
3432
log.d(
3533
'declineReviewRequest.handleDecline',
3634
`User ${user.id} already responded to review ${threadId}, ignoring duplicate click`,
3735
);
3836
return;
3937
}
4038

41-
await declineRequest(this.app, review, user.id);
39+
// declineRequest will throw if user is not in pending (race condition protection)
40+
try {
41+
await declineRequest(this.app, review, user.id);
42+
} catch (err) {
43+
log.d(
44+
'declineReviewRequest.handleDecline',
45+
`User ${user.id} already responded to review ${threadId} (race condition), ignoring duplicate click`,
46+
);
47+
return;
48+
}
4249

4350
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4451
} catch (err: any) {

src/services/RequestService.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -42,32 +42,36 @@ const closeRequestInternal = async (
4242
const priorPendingReviewer = updatedReview.pendingReviewers.find(
4343
({ userId }) => userId === previousUserId,
4444
);
45-
if (priorPendingReviewer) {
46-
updatedReview.pendingReviewers = updatedReview.pendingReviewers.filter(
47-
({ userId }) => userId !== previousUserId,
48-
);
49-
updatedReview.declinedReviewers.push({
50-
userId: previousUserId,
51-
declinedAt: new Date().getTime(),
52-
});
53-
log.d(
54-
'requestService.moveOnToNextPerson',
55-
`Adding ${previousUserId} to declined reviewers for ${activeReview.threadId}`,
56-
);
57-
await activeReviewRepo.update(updatedReview);
58-
const contextBlock = requestBuilder.buildReviewSectionBlock(
59-
{ id: updatedReview.requestorId },
60-
updatedReview.languages,
61-
DeadlineLabel.get(updatedReview.dueBy) || 'Unknown',
62-
);
63-
const closeMessageBlock = textBlock(closeMessage);
64-
await chatService.updateDirectMessage(
65-
app.client,
66-
priorPendingReviewer.userId,
67-
priorPendingReviewer.messageTimestamp,
68-
[contextBlock, closeMessageBlock],
45+
if (!priorPendingReviewer) {
46+
throw new Error(
47+
`${previousUserId} attempted to decline review ${activeReview.threadId} but was not in pending list`,
6948
);
7049
}
50+
51+
updatedReview.pendingReviewers = updatedReview.pendingReviewers.filter(
52+
({ userId }) => userId !== previousUserId,
53+
);
54+
updatedReview.declinedReviewers.push({
55+
userId: previousUserId,
56+
declinedAt: new Date().getTime(),
57+
});
58+
log.d(
59+
'requestService.moveOnToNextPerson',
60+
`Adding ${previousUserId} to declined reviewers for ${activeReview.threadId}`,
61+
);
62+
await activeReviewRepo.update(updatedReview);
63+
const contextBlock = requestBuilder.buildReviewSectionBlock(
64+
{ id: updatedReview.requestorId },
65+
updatedReview.languages,
66+
DeadlineLabel.get(updatedReview.dueBy) || 'Unknown',
67+
);
68+
const closeMessageBlock = textBlock(closeMessage);
69+
await chatService.updateDirectMessage(
70+
app.client,
71+
priorPendingReviewer.userId,
72+
priorPendingReviewer.messageTimestamp,
73+
[contextBlock, closeMessageBlock],
74+
);
7175
return updatedReview;
7276
};
7377

0 commit comments

Comments
 (0)