diff --git a/assets/diagram.drawio.png b/assets/diagram.drawio.png index 0d6af4d..bd05a41 100644 Binary files a/assets/diagram.drawio.png and b/assets/diagram.drawio.png differ diff --git a/jest.config.js b/jest.config.js index 4a3a4e3..dea7af7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -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'] }; diff --git a/jest.setup.js b/jest.setup.js index 051ef2d..dd8dc0b 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -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() }; diff --git a/src/index.spec.ts b/src/index.spec.ts index 3302780..6066cc3 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -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() + ); + }); }); }); diff --git a/src/index.ts b/src/index.ts index 97f77de..2219a71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,7 @@ export const handler: Handler = async (event: SQSEvent): Promise { + 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`; @@ -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'); + }); + }); }); diff --git a/src/services/queue.client.spec.ts b/src/services/queue.client.spec.ts new file mode 100644 index 0000000..926a72c --- /dev/null +++ b/src/services/queue.client.spec.ts @@ -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 + }) + ); + }); +}); diff --git a/src/services/queue.client.ts b/src/services/queue.client.ts index ff34eee..2c68a44 100644 --- a/src/services/queue.client.ts +++ b/src/services/queue.client.ts @@ -29,7 +29,7 @@ export class QueueClient { } public static async resendMessage(request: LinkedinProfileRequest, environment: Environment): Promise { - const attempt = request.attempt++; + const attempt = request.attempt + 1; const queueUrl = environment.AWS_QUEUE_URL; const region = environment.AWS_REGION; @@ -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) { diff --git a/src/test/mocks/linkedin-profile.mocks.ts b/src/test/mocks/linkedin-profile.mocks.ts index adaa8f9..1d1f094 100644 --- a/src/test/mocks/linkedin-profile.mocks.ts +++ b/src/test/mocks/linkedin-profile.mocks.ts @@ -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 => { @@ -52,3 +53,30 @@ export const createMockedLinkedinProfileEmpty = (): LinkedinProfile => { const profile = new LinkedinProfile(); return profile; }; + +export const createMockedLinkedinProfileResponseMac = (customValues: Partial = {}): 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 => { + 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; +}; diff --git a/src/util/__snapshots__/environment.spec.ts.snap b/src/util/__snapshots__/environment.spec.ts.snap new file mode 100644 index 0000000..49e6453 --- /dev/null +++ b/src/util/__snapshots__/environment.spec.ts.snap @@ -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"`; diff --git a/src/util/environment.spec.ts b/src/util/environment.spec.ts new file mode 100644 index 0000000..66af7d1 --- /dev/null +++ b/src/util/environment.spec.ts @@ -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(); + }); +});