Skip to content

Commit 8864d8b

Browse files
committed
feat: handle response and send to queue
1 parent f348c96 commit 8864d8b

17 files changed

+2471
-493
lines changed

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@
1818
"clean": "rm -rf dist node_modules"
1919
},
2020
"dependencies": {
21+
"@aws-sdk/client-sqs": "3.709.0",
2122
"aws-lambda": "1.0.7",
2223
"axios": "1.7.9",
2324
"class-transformer": "0.5.1",
2425
"class-validator": "0.14.1",
2526
"date-fns": "4.1.0",
2627
"envalid": "8.0.0",
28+
"esbuild": "0.24.0",
2729
"reflect-metadata": "0.2.2",
28-
"ts-jest": "29.2.5",
30+
"uuid": "11.0.3",
2931
"winston": "3.17.0"
3032
},
3133
"devDependencies": {
@@ -40,10 +42,12 @@
4042
"eslint": "9.16.0",
4143
"eslint-config-prettier": "9.1.0",
4244
"eslint-plugin-prettier": "5.2.1",
45+
"jest": "29.7.0",
4346
"jest-mock-extended": "3.0.7",
4447
"prettier": "3.4.2",
4548
"prettier-plugin-sort-imports": "1.8.6",
4649
"sqs-consumer": "11.2.0",
50+
"ts-jest": "29.2.5",
4751
"typescript": "5.7.2"
4852
},
4953
"engines": {

src/__snapshots__/index.spec.ts.snap

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3-
exports[`Linkedin lambda handler should handle message and return a response with Linkedin Profile as Mac Profile 1`] = `
3+
exports[`Linkedin lambda handler when handling a successful request should return a response with Linkedin Profile as Mac Profile 1`] = `
44
LinkedinProfileResponse {
5-
"mac": LinkedinProfileResponseMac {
5+
"contextId": "1234",
6+
"importId": "1",
7+
"profile": LinkedinProfileResponseMac {
68
"$schema": "https://raw.githubusercontent.com/getmanfred/mac/v0.5/schema/schema.json",
79
"aboutMe": LinkedinProfileResponseMacAboutMe {
810
"profile": LinkedinProfileResponseMacPerson {
911
"description": "I like to learn new things every day",
12+
"name": "",
13+
"surnames": "",
1014
"title": "Software Engineer @Manfred",
1115
},
1216
},
@@ -55,6 +59,7 @@ LinkedinProfileResponse {
5559
"language": "EN",
5660
},
5761
},
58-
"result": "success",
62+
"profileId": 356,
63+
"timeElapsed": 1000,
5964
}
6065
`;

src/contracts/linkedin-profile.request.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,7 @@ export class LinkedinProfileRequest {
2323

2424
@IsUrl()
2525
public linkedinProfileUrl!: string;
26+
27+
@IsInt()
28+
public attempt!: number;
2629
}

src/contracts/linkedin-profile.response.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
IsDateString,
77
IsIn,
88
IsNotEmpty,
9+
IsNumber,
910
IsObject,
1011
IsOptional,
1112
IsString,
@@ -20,6 +21,12 @@ export class LinkedinProfileResponseMacSettings {
2021
}
2122

2223
export class LinkedinProfileResponseMacPerson {
24+
@IsString()
25+
public name: string = '';
26+
27+
@IsString()
28+
public surnames: string = '';
29+
2330
@IsString()
2431
@IsNotEmpty()
2532
@MaxLength(255)
@@ -192,13 +199,33 @@ export class LinkedinProfileResponseMac {
192199
}
193200
}
194201

202+
export type LinkedinProfileResponseErrorType = 'unknown' | 'account-locked' | 'timeout' | 'expired' | 'invalid-mac' | 'not-found';
203+
195204
export class LinkedinProfileResponse {
196205
@IsString()
197-
@IsIn(['success', 'error'])
198-
public result!: 'success' | 'error';
206+
public importId!: string;
207+
208+
@IsString()
209+
public contextId!: string;
210+
211+
@IsNumber()
212+
public profileId!: number;
199213

214+
@IsOptional()
215+
@IsNumber()
216+
public timeElapsed?: number;
217+
218+
@IsOptional()
200219
@IsObject()
201220
@ValidateNested()
202221
@Type(() => LinkedinProfileResponseMac)
203-
public mac!: LinkedinProfileResponseMac;
222+
public profile?: LinkedinProfileResponseMac;
223+
224+
@IsOptional()
225+
@IsIn(['unknown', 'expired', 'invalid-mac', 'not-found', 'account-locked', 'timeout'])
226+
public errorType?: LinkedinProfileResponseErrorType;
227+
228+
@IsOptional()
229+
@IsString()
230+
public errorMessage?: string;
204231
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class InvalidMacError extends Error {
2+
public constructor(message: string) {
3+
super(message);
4+
}
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class MaxRetriesError extends Error {
2+
public constructor(message: string) {
3+
super(message);
4+
}
5+
}

src/index.spec.ts

Lines changed: 114 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
/* eslint-disable no-process-env */
12
import { Context } from 'aws-lambda';
23
import { mock, mockReset } from 'jest-mock-extended';
4+
import { InvalidMacError } from './domain/errors/invalid-mac.error';
5+
import { MaxRetriesError } from './domain/errors/max-retries.error';
36
import { handler } from './index';
47
import { LinkedinProfileService } from './services/linkedin-profile.service';
8+
import { QueueClient } from './services/queue.client';
59
import {
610
createMockedLinkedinProfile,
711
createMockedLinkedinProfileEmpty,
@@ -15,57 +19,135 @@ jest.mock('./services/linkedin-profile.service', () => ({
1519
LinkedinProfileService: jest.fn(() => linkedinProfileService)
1620
}));
1721

22+
jest.mock('./services/queue.client', () => ({
23+
QueueClient: {
24+
sendToResultQueue: jest.fn().mockResolvedValue(undefined),
25+
resendMessage: jest.fn().mockResolvedValue(undefined)
26+
}
27+
}));
28+
1829
describe('Linkedin lambda handler', () => {
1930
beforeEach(() => {
20-
jest.clearAllMocks();
2131
mockReset(linkedinProfileService);
32+
jest.clearAllMocks();
33+
34+
process.env.AWS_QUEUE_URL = 'queue-url';
35+
process.env.AWS_RESULT_QUEUE_URL = 'result-queue-url';
2236
});
2337

24-
it('should handle message and return a response with Linkedin Profile as Mac Profile', async () => {
25-
const event = createMockedSqsSEvent();
26-
const expectedLinkedinProfile = createMockedLinkedinProfile();
38+
describe('when handling a successful request', () => {
39+
it('should return a response with Linkedin Profile as Mac Profile', async () => {
40+
const event = createMockedSqsSEvent();
41+
const expectedLinkedinProfile = createMockedLinkedinProfile();
42+
43+
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
44+
linkedinProfile: expectedLinkedinProfile,
45+
isEmptyProfile: false,
46+
timeElapsed: 1000
47+
});
2748

28-
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
29-
linkedinProfile: expectedLinkedinProfile,
30-
isEmptyProfile: false
49+
const response = await handler(event, {} as Context, () => {});
50+
51+
expect(response).toMatchSnapshot();
3152
});
3253

33-
const response = await handler(event, {} as Context, () => {});
54+
it('should send response to sqs result queue ', async () => {
55+
const event = createMockedSqsSEvent();
56+
const expectedLinkedinProfile = createMockedLinkedinProfile();
57+
58+
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
59+
linkedinProfile: expectedLinkedinProfile,
60+
isEmptyProfile: false,
61+
timeElapsed: 1000
62+
});
63+
64+
const response = await handler(event, {} as Context, () => {});
3465

35-
expect(response).toMatchSnapshot();
66+
expect(QueueClient.sendToResultQueue).toHaveBeenCalledWith(response, expect.anything());
67+
});
3668
});
3769

38-
it('should throw an error if number of messages is not 1', async () => {
39-
const event = createMockedSqsSEvent();
40-
event.Records = [{ ...event.Records[0] }, { ...event.Records[0] }];
70+
describe('when handling a failed request', () => {
71+
it('should throw an error if number of messages is not 1', async () => {
72+
const event = createMockedSqsSEvent();
73+
event.Records = [{ ...event.Records[0] }, { ...event.Records[0] }];
74+
75+
await expect(handler(event, {} as Context, () => {})).rejects.toThrow(
76+
new Error('[LinkedinProfileRequestMapper] Batch size must be configured to 1')
77+
);
78+
});
79+
80+
it('should throw an error if message is not a valid LinkedinProfileRequest', async () => {
81+
const request = createMockedLinkedinProfileRequest();
82+
request.linkedinApiToken = undefined as unknown as string; // missing required field
83+
const event = createMockedSqsSEvent(request);
84+
const expectedErrorString =
85+
'[LinkedinProfileRequestMapper] Validation failed: ["property: linkedinApiToken errors: linkedinApiToken must be a string"]';
4186

42-
await expect(handler(event, {} as Context, () => {})).rejects.toThrow(
43-
new Error('[LinkedinProfileRequestMapper] Batch size must be configured to 1')
44-
);
87+
await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString));
88+
});
4589
});
4690

47-
it('should throw an error if message is not a valid LinkedinProfileRequest', async () => {
48-
const request = createMockedLinkedinProfileRequest();
49-
request.linkedinApiToken = undefined as unknown as string; // missing required field
50-
const event = createMockedSqsSEvent(request);
51-
const expectedErrorString =
52-
'[LinkedinProfileRequestMapper] Validation failed: ["property: linkedinApiToken errors: linkedinApiToken must be a string"]';
91+
describe('when handling an empty response', () => {
92+
it('should resend message to sqs queue if empty response and no reached max retries ', async () => {
93+
const event = createMockedSqsSEvent();
94+
const expectedLinkedinProfile = createMockedLinkedinProfileEmpty();
95+
96+
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
97+
linkedinProfile: expectedLinkedinProfile,
98+
isEmptyProfile: true,
99+
timeElapsed: 1000
100+
});
101+
102+
await handler(event, {} as Context, () => {});
103+
expect(QueueClient.resendMessage).toHaveBeenCalled();
104+
});
105+
106+
it('should throw an error if max retries reached', async () => {
107+
const request = createMockedLinkedinProfileRequest({ attempt: 3 });
108+
const event = createMockedSqsSEvent(request);
109+
const expectedLinkedinProfile = createMockedLinkedinProfileEmpty();
53110

54-
await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString));
111+
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
112+
linkedinProfile: expectedLinkedinProfile,
113+
isEmptyProfile: true,
114+
timeElapsed: 1000
115+
});
116+
117+
await expect(handler(event, {} as Context, () => {})).rejects.toThrow(MaxRetriesError);
118+
});
55119
});
56120

57-
// TODO: Review this corner case: this should be valid?
58-
it('should throw an error if response is not a valid LinkedinProfileResponse', async () => {
59-
const event = createMockedSqsSEvent();
60-
const expectedLinkedinProfile = createMockedLinkedinProfileEmpty();
61-
const expectedErrorString =
62-
'[LinkedinProfileResponseMapper] MAC Validation failed: ["property: mac.aboutMe.profile.title errors: title should not be empty"]';
121+
describe('when handling a failed response', () => {
122+
it('should throw an error if response is not a valid LinkedinProfileResponse', async () => {
123+
const event = createMockedSqsSEvent();
124+
const expectedLinkedinProfile = createMockedLinkedinProfileEmpty();
125+
const expectedErrorString =
126+
'[LinkedinProfileResponseMapper] MAC Validation failed: ["property: profile.aboutMe.profile.title errors: title should not be empty"]';
127+
128+
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
129+
linkedinProfile: expectedLinkedinProfile,
130+
isEmptyProfile: false,
131+
timeElapsed: 1000
132+
});
63133

64-
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
65-
linkedinProfile: expectedLinkedinProfile,
66-
isEmptyProfile: false
134+
await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new InvalidMacError(expectedErrorString));
67135
});
68136

69-
await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString));
137+
it('should send response to sqs result queue ', async () => {
138+
const event = createMockedSqsSEvent();
139+
const expectedLinkedinProfile = createMockedLinkedinProfileEmpty();
140+
const expectedErrorString =
141+
'[LinkedinProfileResponseMapper] MAC Validation failed: ["property: profile.aboutMe.profile.title errors: title should not be empty"]';
142+
143+
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
144+
linkedinProfile: expectedLinkedinProfile,
145+
isEmptyProfile: false,
146+
timeElapsed: 1000
147+
});
148+
149+
await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString));
150+
expect(QueueClient.sendToResultQueue).toHaveBeenCalled();
151+
});
70152
});
71153
});

src/index.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,43 @@
11
import { Handler, SQSEvent } from 'aws-lambda';
22
import 'reflect-metadata';
33
import { LinkedinProfileResponse } from './contracts/linkedin-profile.response';
4+
import { InvalidMacError } from './domain/errors/invalid-mac.error';
5+
import { MaxRetriesError } from './domain/errors/max-retries.error';
46
import { LinkedinProfileRequestMapper } from './mappers/linkedin-profile.request.mapper';
57
import { LinkedinProfileResponseMapper } from './mappers/linkedin-profile.response.mapper';
68
import { LinkedinProfileService } from './services/linkedin-profile.service';
9+
import { QueueClient } from './services/queue.client';
710
import { Environment } from './util/environment';
811
import { logger } from './util/logger';
912

1013
export const handler: Handler = async (event: SQSEvent): Promise<LinkedinProfileResponse | undefined> => {
14+
const request = LinkedinProfileRequestMapper.toDomain(event);
15+
const env = Environment.setupEnvironment(request);
16+
1117
try {
12-
const linkedinProfileRequest = LinkedinProfileRequestMapper.toDomain(event);
13-
Environment.setupEnvironment(linkedinProfileRequest);
14-
logger.info(
15-
`⌛️ [handler] Starting Linkedin profile request for linkedinProfileUrl: ${linkedinProfileRequest.linkedinProfileUrl}`,
16-
linkedinProfileRequest
17-
);
18+
logger.info(`⌛️ [handler] Starting Linkedin profile request for linkedinProfileUrl: ${request.linkedinProfileUrl}`, request);
1819

19-
const { linkedinProfile, isEmptyProfile } = await new LinkedinProfileService().getLinkedinProfile(linkedinProfileRequest.linkedinApiToken);
20+
const { linkedinProfile, isEmptyProfile, timeElapsed } = await new LinkedinProfileService().getLinkedinProfile(request.linkedinApiToken);
2021

21-
if (isEmptyProfile) {
22-
logger.warn(
23-
`👻 [handler] Linkedin profile is not synced for linkedinProfileUrl: ${linkedinProfileRequest.linkedinProfileUrl}`,
24-
linkedinProfileRequest
25-
);
26-
// TODO: send response to SQS queue again if no max retries
27-
return undefined;
22+
if (!isEmptyProfile && linkedinProfile) {
23+
const linkedinProfileResponse = LinkedinProfileResponseMapper.toResponse(linkedinProfile, request, timeElapsed);
24+
logger.info(`✅ [handler] Linkedin profile response with MAC: ${JSON.stringify(linkedinProfileResponse.profile)}`, request);
25+
await QueueClient.sendToResultQueue(linkedinProfileResponse, env);
26+
return linkedinProfileResponse;
2827
}
2928

30-
const linkedinProfileResponse = LinkedinProfileResponseMapper.toResponse(linkedinProfile);
31-
logger.info(`✅ [handler] Linkedin profile response with MAC: ${JSON.stringify(linkedinProfileResponse.mac)}`, linkedinProfileRequest);
32-
// TODO: send response to result SQS queue
29+
logger.warn(`👻 [handler] Linkedin profile is not synced for linkedinProfileUrl: ${request.linkedinProfileUrl}`, request);
30+
if (request.attempt >= env.MAX_RETRIES) throw new MaxRetriesError(`Max attempts reached for Linkedin profile request: ${env.MAX_RETRIES}`);
31+
await QueueClient.resendMessage(request, env);
32+
33+
return undefined;
34+
} catch (error: unknown) {
35+
const errorType = error instanceof MaxRetriesError ? 'expired' : error instanceof InvalidMacError ? 'invalid-mac' : 'unknown';
36+
const errorMessage = error instanceof Error ? error.message : 'unknown error';
3337

34-
return linkedinProfileResponse;
35-
} catch (error) {
36-
logger.error(`❌ [handler] Error processing Linkedin profile request`, { error, event });
38+
logger.error(`❌ [handler] Error processing Linkedin profile request`, { error, errorType, errorMessage, event });
39+
const result = LinkedinProfileResponseMapper.toErrorResponse(errorType, errorMessage, request);
40+
await QueueClient.sendToResultQueue(result, env);
3741
throw error;
3842
}
3943
};

0 commit comments

Comments
 (0)