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
Binary file modified assets/diagram.drawio.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
coverageReporters: ['lcov', 'text-summary', 'clover'],
collectCoverageFrom: ['src/**/*.ts', '!src/test/**/*', '!src/**/*spec.ts', '!src/launch.ts']
collectCoverageFrom: ['src/**/*.ts', '!src/test/**/*', '!src/**/*spec.ts', '!src/launch.ts', '!src/launch-sqs-consumer.ts']
};
4 changes: 3 additions & 1 deletion jest.setup.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
'use strict';

require('reflect-metadata');

// disable console.log in tests
global.console = {
log: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
debug: jest.fn()
};
16 changes: 16 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,5 +149,21 @@ describe('Linkedin lambda handler', () => {
await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString));
expect(QueueClient.sendToResultQueue).toHaveBeenCalled();
});

it('should handle unknown errors and send to result queue', async () => {
const event = createMockedSqsSEvent();

jest.spyOn(linkedinProfileService, 'getLinkedinProfile').mockRejectedValue(new Error());

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

expect(QueueClient.sendToResultQueue).toHaveBeenCalledWith(
expect.objectContaining({
errorType: 'unknown',
errorMessage: 'unknown error'
}),
expect.anything()
);
});
});
});
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const handler: Handler = async (event: SQSEvent): Promise<LinkedinProfile
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';
const errorMessage = (error as Error)?.message || 'unknown error';

logger.error(`❌ [handler] Error processing Linkedin profile request`, { error, errorType, errorMessage, event });
const result = LinkedinProfileResponseMapper.toErrorResponse(errorType, errorMessage, request);
Expand Down
109 changes: 106 additions & 3 deletions src/services/linkedin-api.client.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import axios from 'axios';
import axios, { AxiosError, AxiosResponse } from 'axios';
import { createMockedLinkedinProfile } from '../test/mocks/linkedin-profile.mocks';
import { LinkedinAPIClient } from './linkedin-api.client';

const client = new LinkedinAPIClient();

jest.mock('axios');
jest.mock('axios', () => {
const originalAxios = jest.requireActual('axios');
return {
...originalAxios,
get: jest.fn()
};
});

describe('A linkedin api client', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should get a domain linkedin profile data using linkedin api', async () => {
it('should get a domain array linkedin profile data using linkedin api', async () => {
const expectedEducation = createMockedLinkedinProfile().education;

const url = `https://api.linkedin.com/rest/memberSnapshotData?q=criteria&domain=EDUCATION`;
Expand All @@ -33,4 +39,101 @@ describe('A linkedin api client', () => {

expect(education).toEqual(expectedEducation);
});

it('should get a domain object linkedin profile data using linkedin api', async () => {
const expectedProfile = createMockedLinkedinProfile().profile;

const url = `https://api.linkedin.com/rest/memberSnapshotData?q=criteria&domain=PROFILE`;
const headers = {
Authorization: `Bearer fake_token`,
'LinkedIn-Version': '202312'
};

(axios.get as jest.Mock).mockImplementation(async (calledUrl, calledOptions) => {
if (calledUrl === url && JSON.stringify(calledOptions.headers) === JSON.stringify(headers)) {
return Promise.resolve({
data: { elements: [{ snapshotData: [expectedProfile] }] }
});
}
return undefined;
});

const education = await client.fetchProfileDomainData('fake_token', 'PROFILE', 'OBJECT');

expect(education).toEqual(expectedProfile);
});

describe('when handling linkedin api errors', () => {
it('should handle error when fetching linkedin profile data', async () => {
const url = `https://api.linkedin.com/rest/memberSnapshotData?q=criteria&domain=PROFILE`;
const headers = {
Authorization: `Bearer fake_token`,
'LinkedIn-Version': '202312'
};

(axios.get as jest.Mock).mockImplementation(async (calledUrl, calledOptions) => {
if (calledUrl === url && JSON.stringify(calledOptions.headers) === JSON.stringify(headers)) {
return Promise.reject({ response: { data: { message: 'Error message' } } });
}
return undefined;
});

await expect(client.fetchProfileDomainData('fake_token', 'PROFILE', 'OBJECT')).rejects.toThrow('Error fetching PROFILE profile data');
});

it('should return an empty array for 404 responses when responseType is ARRAY', async () => {
const mockResponse: AxiosResponse = {
status: 404,
data: null,
headers: {},
config: { headers: new axios.AxiosHeaders() },
statusText: 'Not Found'
};

const mockError = new AxiosError('Not Found', 'ERR_BAD_REQUEST', undefined, null, mockResponse);
Object.setPrototypeOf(mockError, AxiosError.prototype);

(axios.get as jest.Mock).mockRejectedValueOnce(mockError);

const result = await client.fetchProfileDomainData('fake_token', 'SKILLS', 'ARRAY');

expect(result).toEqual([]);
});
it('should return an empty object for 404 responses when responseType is OBJECT', async () => {
const mockResponse: AxiosResponse = {
status: 404,
statusText: 'Not Found',
headers: {},
config: { headers: new axios.AxiosHeaders() },
data: null
};

const mockError = new AxiosError('Request failed with status code 404', 'ERR_BAD_REQUEST', undefined, null, mockResponse);

Object.setPrototypeOf(mockError, AxiosError.prototype);

(axios.get as jest.Mock).mockRejectedValueOnce(mockError);

const result = await client.fetchProfileDomainData('fake_token', 'PROFILE', 'OBJECT');

expect(result).toEqual({});
});

it('should throw an error for non-404 HTTP errors', async () => {
(axios.get as jest.Mock).mockRejectedValueOnce({
response: { status: 500, data: { error: 'Internal Server Error' } },
stack: 'Mocked stack trace'
});

await expect(client.fetchProfileDomainData('fake_token', 'PROFILE', 'OBJECT')).rejects.toThrow('Error fetching PROFILE profile data');
});

it('should throw an error for unexpected response structure', async () => {
(axios.get as jest.Mock).mockResolvedValueOnce({
data: { unexpectedKey: 'unexpectedValue' }
});

await expect(client.fetchProfileDomainData('fake_token', 'PROFILE', 'OBJECT')).rejects.toThrow('Error fetching PROFILE profile data');
});
});
});
63 changes: 63 additions & 0 deletions src/services/queue.client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* eslint-disable @typescript-eslint/no-require-imports */
import { SQSClient } from '@aws-sdk/client-sqs';
import { createMockedLinkedinProfileRequest, createMockedLinkedinProfileResponse } from '../test/mocks/linkedin-profile.mocks';
import { Environment } from '../util/environment';
import { QueueClient } from './queue.client';

jest.mock('@aws-sdk/client-sqs');

describe('QueueClient', () => {
const mockSend = jest.fn();
const mockEnvironment: Environment = {
AWS_RESULT_QUEUE_URL: 'https://sqs.fake/result-queue',
AWS_QUEUE_URL: 'https://sqs.fake/queue',
AWS_REGION: 'us-east-1',
AWS_SQS_ENDPOINT: 'http://localhost:4566',
MAX_RETRIES: 3,
LOGGER_CONSOLE: true
};

beforeAll(() => {
(SQSClient as jest.Mock).mockImplementation(() => ({
send: mockSend
}));
});

beforeEach(() => {
jest.clearAllMocks();
});

it('should send a message to the result queue', async () => {
const response = createMockedLinkedinProfileResponse();

const spySendMessageCommand = jest.spyOn(require('@aws-sdk/client-sqs'), 'SendMessageCommand');

await QueueClient.sendToResultQueue(response, mockEnvironment);

expect(spySendMessageCommand).toHaveBeenCalledWith(
expect.objectContaining({
MessageBody: JSON.stringify(response),
QueueUrl: mockEnvironment.AWS_RESULT_QUEUE_URL,
MessageGroupId: expect.any(String),
MessageDeduplicationId: expect.any(String)
})
);
});

it('should resend a message to the queue', async () => {
const request = createMockedLinkedinProfileRequest();
const spySendMessageCommand = jest.spyOn(require('@aws-sdk/client-sqs'), 'SendMessageCommand');

await QueueClient.resendMessage(request, mockEnvironment);

expect(spySendMessageCommand).toHaveBeenCalledWith(
expect.objectContaining({
MessageBody: JSON.stringify({ ...request, attempt: 2 }),
QueueUrl: mockEnvironment.AWS_QUEUE_URL,
MessageGroupId: expect.any(String),
MessageDeduplicationId: expect.any(String),
DelaySeconds: 120
})
);
});
});
5 changes: 3 additions & 2 deletions src/services/queue.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class QueueClient {
}

public static async resendMessage(request: LinkedinProfileRequest, environment: Environment): Promise<void> {
const attempt = request.attempt++;
const attempt = request.attempt + 1;

const queueUrl = environment.AWS_QUEUE_URL;
const region = environment.AWS_REGION;
Expand All @@ -40,7 +40,8 @@ export class QueueClient {
MessageBody: JSON.stringify({ ...request, attempt }),
QueueUrl: queueUrl,
MessageGroupId: uuid(),
MessageDeduplicationId: uuid()
MessageDeduplicationId: uuid(),
DelaySeconds: 60 * attempt // 1-2-3 minutes
};

if (queueUrl) {
Expand Down
28 changes: 28 additions & 0 deletions src/test/mocks/linkedin-profile.mocks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LinkedinProfileRequest } from '../../contracts/linkedin-profile.request';
import { LinkedinProfileResponse, LinkedinProfileResponseMac } from '../../contracts/linkedin-profile.response';
import { LinkedinProfile } from '../../domain/linkedin-profile';

export const createMockedLinkedinProfileRequest = (customValues: Partial<LinkedinProfileRequest> = {}): LinkedinProfileRequest => {
Expand Down Expand Up @@ -52,3 +53,30 @@ export const createMockedLinkedinProfileEmpty = (): LinkedinProfile => {
const profile = new LinkedinProfile();
return profile;
};

export const createMockedLinkedinProfileResponseMac = (customValues: Partial<LinkedinProfileResponseMac> = {}): LinkedinProfileResponseMac => {
const mac = new LinkedinProfileResponseMac();
mac.$schema = customValues.$schema ?? 'https://raw.githubusercontent.com/getmanfred/mac/v0.5/schema/schema.json';
mac.settings = customValues.settings ?? { language: 'EN' };
mac.aboutMe = customValues.aboutMe ?? {
profile: {
name: 'Pedro',
surnames: 'Manfredo',
title: 'Software Engineer @Manfred',
description: 'I like to learn new things every day'
}
};
mac.experience = customValues.experience ?? {};
mac.knowledge = customValues.knowledge ?? {};
return mac;
};

export const createMockedLinkedinProfileResponse = (customValues: Partial<LinkedinProfileResponse> = {}): LinkedinProfileResponse => {
const response = new LinkedinProfileResponse();
response.importId = customValues.importId ?? '1';
response.contextId = customValues.contextId ?? '1234';
response.profileId = customValues.profileId ?? 356;
response.timeElapsed = customValues.timeElapsed ?? 1000;
response.profile = customValues.profile ?? createMockedLinkedinProfileResponseMac();
return response;
};
27 changes: 27 additions & 0 deletions src/util/__snapshots__/environment.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Environment config should correctly load environment variables for "pro" environment and match snapshot 1`] = `
Environment {
"AWS_QUEUE_URL": "https://sqs-pro.amazonaws.com/1234567890/pro-queue",
"AWS_REGION": "us-east-1",
"AWS_RESULT_QUEUE_URL": "https://sqs-pro.amazonaws.com/1234567890/pro-result-queue",
"AWS_SQS_ENDPOINT": "http://localhost:4566",
"LOCAL_LINKEDIN_API_TOKEN": "mock-api-token",
"LOGGER_CONSOLE": true,
"MAX_RETRIES": 5,
}
`;

exports[`Environment config should correctly load environment variables for "stage" environment and match snapshot 1`] = `
Environment {
"AWS_QUEUE_URL": "https://sqs-stage.amazonaws.com/1234567890/stage-queue",
"AWS_REGION": "us-east-1",
"AWS_RESULT_QUEUE_URL": "https://sqs-stage.amazonaws.com/1234567890/stage-result-queue",
"AWS_SQS_ENDPOINT": "http://localhost:4566",
"LOCAL_LINKEDIN_API_TOKEN": "mock-api-token",
"LOGGER_CONSOLE": true,
"MAX_RETRIES": 5,
}
`;

exports[`Environment config should throw an error when environment variables are invalid 1`] = `"🔧 [Environment] Invalid environment variables: process.exit called"`;
55 changes: 55 additions & 0 deletions src/util/environment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/* eslint-disable no-process-env */
import { createMockedLinkedinProfileRequest } from '../test/mocks/linkedin-profile.mocks';
import { Environment } from './environment';

describe('Environment config', () => {
const mockEnv = {
MAX_RETRIES: '5',
LOGGER_CONSOLE: 'true',
LOCAL_LINKEDIN_API_TOKEN: 'mock-api-token',
AWS_REGION: 'us-east-1',
AWS_SQS_ENDPOINT: 'http://localhost:4566',
AWS_QUEUE_URL_STAGE: 'https://sqs-stage.amazonaws.com/1234567890/stage-queue',
AWS_RESULT_QUEUE_URL_STAGE: 'https://sqs-stage.amazonaws.com/1234567890/stage-result-queue',
AWS_QUEUE_URL_PRO: 'https://sqs-pro.amazonaws.com/1234567890/pro-queue',
AWS_RESULT_QUEUE_URL_PRO: 'https://sqs-pro.amazonaws.com/1234567890/pro-result-queue'
};

beforeEach(() => {
process.env = { ...mockEnv };
});

afterEach(() => {
jest.resetModules();
});

it('should correctly load environment variables for "stage" environment and match snapshot', () => {
const request = createMockedLinkedinProfileRequest({ env: 'stage' });

const environment = Environment.setupEnvironment(request);

expect(environment).toMatchSnapshot();
});

it('should correctly load environment variables for "pro" environment and match snapshot', () => {
const request = createMockedLinkedinProfileRequest({ env: 'pro' });

const environment = Environment.setupEnvironment(request);

expect(environment).toMatchSnapshot();
});

it('should throw an error when environment variables are invalid', () => {
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});

process.env.MAX_RETRIES = 'invalid';

const request = createMockedLinkedinProfileRequest({ env: 'pro' });

expect(() => Environment.setupEnvironment(request)).toThrowErrorMatchingSnapshot();

mockExit.mockRestore();
});
});
Loading