Skip to content

Commit 38a5216

Browse files
committed
Enhance AwsBedrockService and AwsTextractService with user ID-based rate limiting
- Updated AwsBedrockService to include user ID as a parameter in analyzeMedicalDocument and generateResponse methods for improved rate limiting. - Refactored AwsTextractService to replace client IP with user ID in extractText and processBatch methods, ensuring consistent rate limiting across services. - Enhanced unit tests in aws-bedrock.service.spec.ts and aws-textract.service.spec.ts to validate the new user ID-based rate limiting functionality, including handling of rate limit exceptions.
1 parent e332a5c commit 38a5216

File tree

5 files changed

+152
-88
lines changed

5 files changed

+152
-88
lines changed

backend/src/services/aws-bedrock.service.spec.ts

Lines changed: 102 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,39 @@ describe('AwsBedrockService', () => {
114114
let mockConfigService: ConfigService;
115115
const originalEnv = process.env.NODE_ENV;
116116

117+
// Define sample text and mock analysis result
118+
const sampleText = `
119+
BLOOD TEST RESULTS
120+
Patient: John Doe
121+
Date: 2023-01-15
122+
123+
Red Blood Cells (RBC): 5.1 x10^6/µL (Normal: 4.5-5.9)
124+
White Blood Cells (WBC): 7.2 x10^3/µL (Normal: 4.5-11.0)
125+
Hemoglobin: 14.2 g/dL (Normal: 13.5-17.5)
126+
`;
127+
128+
const mockMedicalAnalysis: MedicalDocumentAnalysis = {
129+
keyMedicalTerms: [
130+
{ term: 'RBC', definition: 'Red Blood Cells' },
131+
{ term: 'WBC', definition: 'White Blood Cells' },
132+
],
133+
labValues: [
134+
{
135+
name: 'Hemoglobin',
136+
value: '14.2',
137+
unit: 'g/dL',
138+
normalRange: '13.5-17.5',
139+
isAbnormal: false,
140+
},
141+
],
142+
diagnoses: [],
143+
metadata: {
144+
isMedicalReport: true,
145+
confidence: 0.95,
146+
missingInformation: [],
147+
},
148+
};
149+
117150
beforeAll(() => {
118151
process.env.NODE_ENV = 'test';
119152
});
@@ -157,94 +190,97 @@ describe('AwsBedrockService', () => {
157190

158191
describe('analyzeMedicalDocument', () => {
159192
it('should successfully analyze a valid medical document', async () => {
160-
// Create a sample medical document text
161-
const sampleText = `
162-
BLOOD TEST RESULTS
163-
Patient: John Doe
164-
Date: 2023-01-15
165-
166-
Red Blood Cells (RBC): 5.1 x10^6/µL (Normal: 4.5-5.9)
167-
White Blood Cells (WBC): 7.2 x10^3/µL (Normal: 4.5-11.0)
168-
Hemoglobin: 14.2 g/dL (Normal: 13.5-17.5)
169-
`;
170-
171-
// Call the method
172-
const result = await service.analyzeMedicalDocument(sampleText);
173-
174-
// Assert the result
175-
expect(result).toBeDefined();
176-
expect(result.keyMedicalTerms).toHaveLength(2);
177-
expect(result.keyMedicalTerms[0].term).toBe('RBC');
178-
expect(result.keyMedicalTerms[0].definition).toBe('Red Blood Cells');
179-
180-
expect(result.labValues).toHaveLength(1);
181-
expect(result.labValues[0].name).toBe('Hemoglobin');
182-
expect(result.labValues[0].value).toBe('14.2');
183-
expect(result.labValues[0].unit).toBe('g/dL');
184-
expect(result.labValues[0].isAbnormal).toBe(false);
185-
186-
expect(result.metadata.isMedicalReport).toBe(true);
187-
expect(result.metadata.confidence).toBeGreaterThan(0.9);
188-
});
193+
// Create mock response
194+
const mockResponse = {
195+
body: Buffer.from(
196+
JSON.stringify({
197+
content: [
198+
{
199+
text: JSON.stringify(mockMedicalAnalysis),
200+
},
201+
],
202+
}),
203+
),
204+
};
189205

190-
it('should correctly format the prompt for medical document analysis', async () => {
191-
// Spy on the invokeBedrock method
192-
const invokeBedrockSpy = vi.spyOn(service as any, 'invokeBedrock');
206+
// Mock the invokeBedrock method instead of directly setting the client
207+
vi.spyOn(service as any, 'invokeBedrock').mockResolvedValue(mockResponse);
193208

194-
// Sample document text
195-
const sampleText = 'Sample medical document';
209+
// Call service with user ID
210+
const mockUserId = 'test-user-123';
211+
const result = await service.analyzeMedicalDocument(sampleText, mockUserId);
196212

197-
try {
198-
await service.analyzeMedicalDocument(sampleText);
199-
} catch (error) {
200-
// We don't care about the result, just the prompt format
201-
}
213+
// Verify results
214+
expect(result).toEqual(mockMedicalAnalysis);
202215

203-
// Verify invokeBedrock was called
204-
expect(invokeBedrockSpy).toHaveBeenCalled();
216+
// Verify the invokeBedrock was called with the correct prompt
217+
expect(service['invokeBedrock']).toHaveBeenCalled();
218+
const prompt = (service['invokeBedrock'] as jest.Mock).mock.calls[0][0];
219+
expect(prompt).toContain('Please analyze this medical document carefully');
220+
});
205221

206-
// Verify the prompt format
207-
const prompt = invokeBedrockSpy.mock.calls[0][0] as string;
222+
it('should correctly format the request for Claude models', async () => {
223+
// Create mock response
224+
const mockResponse = {
225+
body: Buffer.from(
226+
JSON.stringify({
227+
content: [{ text: JSON.stringify(mockMedicalAnalysis) }],
228+
}),
229+
),
230+
};
208231

209-
// Check key elements of the prompt
232+
// Mock the invokeBedrock method
233+
vi.spyOn(service as any, 'invokeBedrock').mockResolvedValue(mockResponse);
234+
235+
// Call service with user ID
236+
const mockUserId = 'test-user-123';
237+
await service.analyzeMedicalDocument(sampleText, mockUserId);
238+
239+
// Verify the invokeBedrock was called with the correct prompt
240+
expect(service['invokeBedrock']).toHaveBeenCalled();
241+
const prompt = (service['invokeBedrock'] as jest.Mock).mock.calls[0][0];
210242
expect(prompt).toContain('Please analyze this medical document carefully');
211-
expect(prompt).toContain('Format the response as a JSON object');
212-
expect(prompt).toContain('keyMedicalTerms');
213-
expect(prompt).toContain('labValues');
214-
expect(prompt).toContain('diagnoses');
215-
expect(prompt).toContain('metadata');
216-
expect(prompt).toContain('Sample medical document'); // Document text is appended
217243
});
218244

219-
it('should throw BadRequestException for invalid JSON response', async () => {
220-
// Create a sample invalid document text
221-
const invalidDocument = 'This is an invalid document that will cause an invalid response';
245+
it('should throw an error for invalid input', async () => {
246+
const invalidDocument = '';
222247

223-
// Expect the method to throw BadRequestException
224-
await expect(service.analyzeMedicalDocument(invalidDocument)).rejects.toThrow(
248+
// Call with user ID
249+
const mockUserId = 'test-user-123';
250+
await expect(service.analyzeMedicalDocument(invalidDocument, mockUserId)).rejects.toThrow(
225251
BadRequestException,
226252
);
227253
});
228254

229-
it('should throw BadRequestException for empty response', async () => {
230-
// Create a sample text that will trigger an empty response
255+
it('should throw error for empty response', async () => {
256+
// Create mock response with empty content
257+
const mockResponse = {
258+
body: Buffer.from(
259+
JSON.stringify({
260+
content: [],
261+
}),
262+
),
263+
};
264+
265+
// Mock the invokeBedrock method
266+
vi.spyOn(service as any, 'invokeBedrock').mockResolvedValue(mockResponse);
267+
231268
const emptyResponseText = 'This will trigger an empty response';
232269

233-
// Expect the method to throw BadRequestException
234-
await expect(service.analyzeMedicalDocument(emptyResponseText)).rejects.toThrow(
270+
// Call with user ID
271+
const mockUserId = 'test-user-123';
272+
await expect(service.analyzeMedicalDocument(emptyResponseText, mockUserId)).rejects.toThrow(
235273
BadRequestException,
236274
);
237275
});
238276

239-
it('should handle rate limiting correctly', async () => {
240-
// Mock the rate limiter to reject requests
277+
it('should handle rate limiting', async () => {
278+
// Mock rate limiter to reject the request
241279
service['rateLimiter'].tryRequest = vi.fn().mockReturnValue(false);
242280

243-
// Create a sample medical document text
244-
const sampleText = 'Sample medical document text';
245-
246-
// Expect the method to throw BadRequestException due to rate limiting
247-
await expect(service.analyzeMedicalDocument(sampleText)).rejects.toThrow(
281+
// Call with user ID
282+
const mockUserId = 'test-user-123';
283+
await expect(service.analyzeMedicalDocument(sampleText, mockUserId)).rejects.toThrow(
248284
'Rate limit exceeded',
249285
);
250286
});

backend/src/services/aws-bedrock.service.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -349,10 +349,13 @@ Document text:
349349

350350
/**
351351
* Generates a response using AWS Bedrock
352+
* @param prompt The prompt to send to the model
353+
* @param userId The authenticated user's ID for rate limiting
354+
* @returns The generated response text
352355
*/
353-
async generateResponse(prompt: string): Promise<string> {
354-
// Check rate limiting
355-
if (!this.rateLimiter.tryRequest('global')) {
356+
async generateResponse(prompt: string, userId: string): Promise<string> {
357+
// Check rate limiting using user ID
358+
if (!this.rateLimiter.tryRequest(userId)) {
356359
throw new BadRequestException('Rate limit exceeded. Please try again later.');
357360
}
358361

@@ -372,11 +375,15 @@ Document text:
372375
/**
373376
* Analyzes a medical document using Claude model and returns structured data
374377
* @param documentText The text content of the medical document to analyze
378+
* @param userId The authenticated user's ID for rate limiting
375379
* @returns Structured analysis of the medical document
376380
*/
377-
async analyzeMedicalDocument(documentText: string): Promise<MedicalDocumentAnalysis> {
378-
// Check rate limiting
379-
if (!this.rateLimiter.tryRequest('medical-analysis')) {
381+
async analyzeMedicalDocument(
382+
documentText: string,
383+
userId: string,
384+
): Promise<MedicalDocumentAnalysis> {
385+
// Check rate limiting using user ID
386+
if (!this.rateLimiter.tryRequest(userId)) {
380387
throw new BadRequestException('Rate limit exceeded. Please try again later.');
381388
}
382389

backend/src/services/aws-textract.service.spec.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ describe('AwsTextractService', () => {
176176
const result = await service.extractText(
177177
Buffer.from('test image content'),
178178
'image/jpeg',
179-
'127.0.0.1',
179+
'user-123',
180180
);
181181

182182
expect(result).toBeDefined();
@@ -191,14 +191,30 @@ describe('AwsTextractService', () => {
191191
const result = await service.extractText(
192192
Buffer.from('test pdf content'),
193193
'application/pdf',
194-
'127.0.0.1',
194+
'user-123',
195195
);
196196

197197
expect(result).toBeDefined();
198198
expect(result.rawText).toContain('This is a test medical report');
199199
expect(result.lines.length).toBeGreaterThan(0);
200200
expect(mockTextractSend).toHaveBeenCalled();
201201
});
202+
203+
it('should handle rate limiting by user ID', async () => {
204+
// Mock rate limiter to reject the request
205+
(service['rateLimiter'].tryRequest as jest.Mock).mockReturnValueOnce(false);
206+
207+
// Use a test user ID
208+
const userId = 'rate-limited-user';
209+
210+
// Should throw rate limit exception
211+
await expect(
212+
service.extractText(Buffer.from('test content'), 'image/jpeg', userId),
213+
).rejects.toThrow('Too many requests');
214+
215+
// The textract API should not be called
216+
expect(mockTextractSend).not.toHaveBeenCalled();
217+
});
202218
});
203219

204220
describe('processBatch', () => {
@@ -214,7 +230,7 @@ describe('AwsTextractService', () => {
214230
},
215231
];
216232

217-
const results = await service.processBatch(documents, '127.0.0.1');
233+
const results = await service.processBatch(documents, 'user-123');
218234

219235
expect(results).toBeDefined();
220236
expect(results.length).toBe(2);
@@ -229,7 +245,7 @@ describe('AwsTextractService', () => {
229245
type: 'image/jpeg',
230246
});
231247

232-
await expect(service.processBatch(documents, '127.0.0.1')).rejects.toThrow(
248+
await expect(service.processBatch(documents, 'user-123')).rejects.toThrow(
233249
BadRequestException,
234250
);
235251
expect(mockTextractSend).not.toHaveBeenCalled();

backend/src/services/aws-textract.service.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,19 @@ export class AwsTextractService {
8282
* Extract text from a medical lab report image or PDF
8383
* @param fileBuffer The file buffer containing the image or PDF
8484
* @param fileType The MIME type of the file (e.g., 'image/jpeg', 'application/pdf')
85-
* @param clientIp Optional client IP for rate limiting
85+
* @param userId The authenticated user's ID for rate limiting
8686
* @returns Extracted text result with structured information
8787
*/
8888
async extractText(
8989
fileBuffer: Buffer,
9090
fileType: string,
91-
clientIp?: string,
91+
userId: string,
9292
): Promise<ExtractedTextResult> {
9393
try {
9494
const startTime = Date.now();
9595

9696
// 1. Rate limiting check
97-
if (clientIp && !this.rateLimiter.tryRequest(clientIp)) {
97+
if (!this.rateLimiter.tryRequest(userId)) {
9898
throw new BadRequestException('Too many requests. Please try again later.');
9999
}
100100

@@ -136,7 +136,7 @@ export class AwsTextractService {
136136
error: error instanceof Error ? error.message : 'Unknown error',
137137
fileType,
138138
timestamp: new Date().toISOString(),
139-
clientIp: clientIp ? this.hashIdentifier(clientIp) : undefined,
139+
userId: this.hashIdentifier(userId),
140140
});
141141

142142
if (error instanceof BadRequestException) {
@@ -374,12 +374,12 @@ export class AwsTextractService {
374374
/**
375375
* Process multiple documents in batch
376376
* @param documents Array of document buffers with their types
377-
* @param clientIp Optional client IP for rate limiting
377+
* @param userId The authenticated user's ID for rate limiting
378378
* @returns Array of extracted text results
379379
*/
380380
async processBatch(
381381
documents: Array<{ buffer: Buffer; type: string }>,
382-
clientIp?: string,
382+
userId: string,
383383
): Promise<ExtractedTextResult[]> {
384384
// Validate batch size
385385
if (documents.length > 10) {
@@ -392,7 +392,7 @@ export class AwsTextractService {
392392

393393
for (const doc of documents) {
394394
try {
395-
const result = await this.extractText(doc.buffer, doc.type, clientIp);
395+
const result = await this.extractText(doc.buffer, doc.type, userId);
396396
results.push(result);
397397
} catch (error) {
398398
this.logger.error('Error processing document in batch', {

backend/src/utils/security.utils.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -279,14 +279,19 @@ export class RateLimiter {
279279
this.maxRequests = maxRequests;
280280
}
281281

282-
public tryRequest(identifier: string): boolean {
282+
/**
283+
* Attempts to register a new request for the given user ID
284+
* @param userId The authenticated user's unique identifier
285+
* @returns boolean True if the request is allowed, false if rate limit exceeded
286+
*/
287+
public tryRequest(userId: string): boolean {
283288
const now = Date.now();
284289
const windowStart = now - this.windowMs;
285290

286-
// Get or initialize request timestamps for this identifier
287-
let timestamps = this.requests.get(identifier) || [];
291+
// Get or initialize request timestamps for this user
292+
let timestamps = this.requests.get(userId) || [];
288293

289-
// Remove old timestamps
294+
// Remove old timestamps outside the current window
290295
timestamps = timestamps.filter(time => time > windowStart);
291296

292297
// Check if limit is reached
@@ -296,7 +301,7 @@ export class RateLimiter {
296301

297302
// Add new request timestamp
298303
timestamps.push(now);
299-
this.requests.set(identifier, timestamps);
304+
this.requests.set(userId, timestamps);
300305

301306
return true;
302307
}

0 commit comments

Comments
 (0)