Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@
"clean": "rm -rf dist node_modules"
},
"dependencies": {
"@aws-sdk/client-sqs": "3.709.0",
"aws-lambda": "1.0.7",
"axios": "1.7.9",
"class-transformer": "0.5.1",
"class-validator": "0.14.1",
"date-fns": "4.1.0",
"envalid": "8.0.0",
"esbuild": "0.24.0",
"reflect-metadata": "0.2.2",
"ts-jest": "29.2.5",
"uuid": "11.0.3",
"winston": "3.17.0"
},
"devDependencies": {
Expand All @@ -40,10 +42,12 @@
"eslint": "9.16.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-prettier": "5.2.1",
"jest": "29.7.0",
"jest-mock-extended": "3.0.7",
"prettier": "3.4.2",
"prettier-plugin-sort-imports": "1.8.6",
"sqs-consumer": "11.2.0",
"ts-jest": "29.2.5",
"typescript": "5.7.2"
},
"engines": {
Expand Down
11 changes: 8 additions & 3 deletions src/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Linkedin lambda handler should handle message and return a response with Linkedin Profile as Mac Profile 1`] = `
exports[`Linkedin lambda handler when handling a successful request should return a response with Linkedin Profile as Mac Profile 1`] = `
LinkedinProfileResponse {
"mac": LinkedinProfileResponseMac {
"contextId": "1234",
"importId": "1",
"profile": LinkedinProfileResponseMac {
"$schema": "https://raw.githubusercontent.com/getmanfred/mac/v0.5/schema/schema.json",
"aboutMe": LinkedinProfileResponseMacAboutMe {
"profile": LinkedinProfileResponseMacPerson {
"description": "I like to learn new things every day",
"name": "",
"surnames": "",
"title": "Software Engineer @Manfred",
},
},
Expand Down Expand Up @@ -55,6 +59,7 @@ LinkedinProfileResponse {
"language": "EN",
},
},
"result": "success",
"profileId": 356,
"timeElapsed": 1000,
}
`;
3 changes: 3 additions & 0 deletions src/contracts/linkedin-profile.request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,7 @@ export class LinkedinProfileRequest {

@IsUrl()
public linkedinProfileUrl!: string;

@IsInt()
public attempt!: number;
}
33 changes: 30 additions & 3 deletions src/contracts/linkedin-profile.response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
IsDateString,
IsIn,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
Expand All @@ -20,6 +21,12 @@ export class LinkedinProfileResponseMacSettings {
}

export class LinkedinProfileResponseMacPerson {
@IsString()
public name: string = '';

@IsString()
public surnames: string = '';

@IsString()
@IsNotEmpty()
@MaxLength(255)
Expand Down Expand Up @@ -192,13 +199,33 @@ export class LinkedinProfileResponseMac {
}
}

export type LinkedinProfileResponseErrorType = 'unknown' | 'account-locked' | 'timeout' | 'expired' | 'invalid-mac' | 'not-found';

export class LinkedinProfileResponse {
@IsString()
@IsIn(['success', 'error'])
public result!: 'success' | 'error';
public importId!: string;

@IsString()
public contextId!: string;

@IsNumber()
public profileId!: number;

@IsOptional()
@IsNumber()
public timeElapsed?: number;

@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => LinkedinProfileResponseMac)
public mac!: LinkedinProfileResponseMac;
public profile?: LinkedinProfileResponseMac;

@IsOptional()
@IsIn(['unknown', 'expired', 'invalid-mac', 'not-found', 'account-locked', 'timeout'])
public errorType?: LinkedinProfileResponseErrorType;

@IsOptional()
@IsString()
public errorMessage?: string;
}
5 changes: 5 additions & 0 deletions src/domain/errors/invalid-mac.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class InvalidMacError extends Error {
public constructor(message: string) {
super(message);
}
}
5 changes: 5 additions & 0 deletions src/domain/errors/max-retries.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class MaxRetriesError extends Error {
public constructor(message: string) {
super(message);
}
}
146 changes: 114 additions & 32 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
/* eslint-disable no-process-env */
import { Context } from 'aws-lambda';
import { mock, mockReset } from 'jest-mock-extended';
import { InvalidMacError } from './domain/errors/invalid-mac.error';
import { MaxRetriesError } from './domain/errors/max-retries.error';
import { handler } from './index';
import { LinkedinProfileService } from './services/linkedin-profile.service';
import { QueueClient } from './services/queue.client';
import {
createMockedLinkedinProfile,
createMockedLinkedinProfileEmpty,
Expand All @@ -15,57 +19,135 @@ jest.mock('./services/linkedin-profile.service', () => ({
LinkedinProfileService: jest.fn(() => linkedinProfileService)
}));

jest.mock('./services/queue.client', () => ({
QueueClient: {
sendToResultQueue: jest.fn().mockResolvedValue(undefined),
resendMessage: jest.fn().mockResolvedValue(undefined)
}
}));

describe('Linkedin lambda handler', () => {
beforeEach(() => {
jest.clearAllMocks();
mockReset(linkedinProfileService);
jest.clearAllMocks();

process.env.AWS_QUEUE_URL = 'queue-url';
process.env.AWS_RESULT_QUEUE_URL = 'result-queue-url';
});

it('should handle message and return a response with Linkedin Profile as Mac Profile', async () => {
const event = createMockedSqsSEvent();
const expectedLinkedinProfile = createMockedLinkedinProfile();
describe('when handling a successful request', () => {
it('should return a response with Linkedin Profile as Mac Profile', async () => {
const event = createMockedSqsSEvent();
const expectedLinkedinProfile = createMockedLinkedinProfile();

linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
linkedinProfile: expectedLinkedinProfile,
isEmptyProfile: false,
timeElapsed: 1000
});

linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
linkedinProfile: expectedLinkedinProfile,
isEmptyProfile: false
const response = await handler(event, {} as Context, () => {});

expect(response).toMatchSnapshot();
});

const response = await handler(event, {} as Context, () => {});
it('should send response to sqs result queue ', async () => {
const event = createMockedSqsSEvent();
const expectedLinkedinProfile = createMockedLinkedinProfile();

linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
linkedinProfile: expectedLinkedinProfile,
isEmptyProfile: false,
timeElapsed: 1000
});

const response = await handler(event, {} as Context, () => {});

expect(response).toMatchSnapshot();
expect(QueueClient.sendToResultQueue).toHaveBeenCalledWith(response, expect.anything());
});
});

it('should throw an error if number of messages is not 1', async () => {
const event = createMockedSqsSEvent();
event.Records = [{ ...event.Records[0] }, { ...event.Records[0] }];
describe('when handling a failed request', () => {
it('should throw an error if number of messages is not 1', async () => {
const event = createMockedSqsSEvent();
event.Records = [{ ...event.Records[0] }, { ...event.Records[0] }];

await expect(handler(event, {} as Context, () => {})).rejects.toThrow(
new Error('[LinkedinProfileRequestMapper] Batch size must be configured to 1')
);
});

it('should throw an error if message is not a valid LinkedinProfileRequest', async () => {
const request = createMockedLinkedinProfileRequest();
request.linkedinApiToken = undefined as unknown as string; // missing required field
const event = createMockedSqsSEvent(request);
const expectedErrorString =
'[LinkedinProfileRequestMapper] Validation failed: ["property: linkedinApiToken errors: linkedinApiToken must be a string"]';

await expect(handler(event, {} as Context, () => {})).rejects.toThrow(
new Error('[LinkedinProfileRequestMapper] Batch size must be configured to 1')
);
await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString));
});
});

it('should throw an error if message is not a valid LinkedinProfileRequest', async () => {
const request = createMockedLinkedinProfileRequest();
request.linkedinApiToken = undefined as unknown as string; // missing required field
const event = createMockedSqsSEvent(request);
const expectedErrorString =
'[LinkedinProfileRequestMapper] Validation failed: ["property: linkedinApiToken errors: linkedinApiToken must be a string"]';
describe('when handling an empty response', () => {
it('should resend message to sqs queue if empty response and no reached max retries ', async () => {
const event = createMockedSqsSEvent();
const expectedLinkedinProfile = createMockedLinkedinProfileEmpty();

linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
linkedinProfile: expectedLinkedinProfile,
isEmptyProfile: true,
timeElapsed: 1000
});

await handler(event, {} as Context, () => {});
expect(QueueClient.resendMessage).toHaveBeenCalled();
});

it('should throw an error if max retries reached', async () => {
const request = createMockedLinkedinProfileRequest({ attempt: 3 });
const event = createMockedSqsSEvent(request);
const expectedLinkedinProfile = createMockedLinkedinProfileEmpty();

await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString));
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
linkedinProfile: expectedLinkedinProfile,
isEmptyProfile: true,
timeElapsed: 1000
});

await expect(handler(event, {} as Context, () => {})).rejects.toThrow(MaxRetriesError);
});
});

// TODO: Review this corner case: this should be valid?
it('should throw an error if response is not a valid LinkedinProfileResponse', async () => {
const event = createMockedSqsSEvent();
const expectedLinkedinProfile = createMockedLinkedinProfileEmpty();
const expectedErrorString =
'[LinkedinProfileResponseMapper] MAC Validation failed: ["property: mac.aboutMe.profile.title errors: title should not be empty"]';
describe('when handling a failed response', () => {
it('should throw an error if response is not a valid LinkedinProfileResponse', async () => {
const event = createMockedSqsSEvent();
const expectedLinkedinProfile = createMockedLinkedinProfileEmpty();
const expectedErrorString =
'[LinkedinProfileResponseMapper] MAC Validation failed: ["property: profile.aboutMe.profile.title errors: title should not be empty"]';

linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
linkedinProfile: expectedLinkedinProfile,
isEmptyProfile: false,
timeElapsed: 1000
});

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

await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString));
it('should send response to sqs result queue ', async () => {
const event = createMockedSqsSEvent();
const expectedLinkedinProfile = createMockedLinkedinProfileEmpty();
const expectedErrorString =
'[LinkedinProfileResponseMapper] MAC Validation failed: ["property: profile.aboutMe.profile.title errors: title should not be empty"]';

linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
linkedinProfile: expectedLinkedinProfile,
isEmptyProfile: false,
timeElapsed: 1000
});

await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString));
expect(QueueClient.sendToResultQueue).toHaveBeenCalled();
});
});
});
44 changes: 24 additions & 20 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,43 @@
import { Handler, SQSEvent } from 'aws-lambda';
import 'reflect-metadata';
import { LinkedinProfileResponse } from './contracts/linkedin-profile.response';
import { InvalidMacError } from './domain/errors/invalid-mac.error';
import { MaxRetriesError } from './domain/errors/max-retries.error';
import { LinkedinProfileRequestMapper } from './mappers/linkedin-profile.request.mapper';
import { LinkedinProfileResponseMapper } from './mappers/linkedin-profile.response.mapper';
import { LinkedinProfileService } from './services/linkedin-profile.service';
import { QueueClient } from './services/queue.client';
import { Environment } from './util/environment';
import { logger } from './util/logger';

export const handler: Handler = async (event: SQSEvent): Promise<LinkedinProfileResponse | undefined> => {
const request = LinkedinProfileRequestMapper.toDomain(event);
const env = Environment.setupEnvironment(request);

try {
const linkedinProfileRequest = LinkedinProfileRequestMapper.toDomain(event);
Environment.setupEnvironment(linkedinProfileRequest);
logger.info(
`⌛️ [handler] Starting Linkedin profile request for linkedinProfileUrl: ${linkedinProfileRequest.linkedinProfileUrl}`,
linkedinProfileRequest
);
logger.info(`⌛️ [handler] Starting Linkedin profile request for linkedinProfileUrl: ${request.linkedinProfileUrl}`, request);

const { linkedinProfile, isEmptyProfile } = await new LinkedinProfileService().getLinkedinProfile(linkedinProfileRequest.linkedinApiToken);
const { linkedinProfile, isEmptyProfile, timeElapsed } = await new LinkedinProfileService().getLinkedinProfile(request.linkedinApiToken);

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

const linkedinProfileResponse = LinkedinProfileResponseMapper.toResponse(linkedinProfile);
logger.info(`✅ [handler] Linkedin profile response with MAC: ${JSON.stringify(linkedinProfileResponse.mac)}`, linkedinProfileRequest);
// TODO: send response to result SQS queue
logger.warn(`👻 [handler] Linkedin profile is not synced for linkedinProfileUrl: ${request.linkedinProfileUrl}`, request);
if (request.attempt >= env.MAX_RETRIES) throw new MaxRetriesError(`Max attempts reached for Linkedin profile request: ${env.MAX_RETRIES}`);
await QueueClient.resendMessage(request, env);

return undefined;
} catch (error: unknown) {
const errorType = error instanceof MaxRetriesError ? 'expired' : error instanceof InvalidMacError ? 'invalid-mac' : 'unknown';
const errorMessage = error instanceof Error ? error.message : 'unknown error';

return linkedinProfileResponse;
} catch (error) {
logger.error(`❌ [handler] Error processing Linkedin profile request`, { error, event });
logger.error(`❌ [handler] Error processing Linkedin profile request`, { error, errorType, errorMessage, event });
const result = LinkedinProfileResponseMapper.toErrorResponse(errorType, errorMessage, request);
await QueueClient.sendToResultQueue(result, env);
throw error;
}
};
Loading
Loading