Skip to content

Commit 737bb71

Browse files
authored
feat: improve dates parser & catch empty response (ASG-4721 #IN_PROGRESS) (#4)
1 parent ee653a1 commit 737bb71

File tree

10 files changed

+103
-55
lines changed

10 files changed

+103
-55
lines changed

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,5 @@ Make sure you have all necessary environment variables and dependencies set up b
5050
- error handling, detect "empty" response (not yet connected)
5151
- measure time and return to storage in DB for metabase stats
5252
- more tests, integration test?
53-
- Github action to deploy in aws
5453
- Send result to SQS queue
5554
- Script to get linkedin token
56-
- Review dates parser: fallback MMM yyyy -> yyyy ?

src/index.spec.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ describe('Linkedin lambda handler', () => {
2525
const event = createMockedSqsSEvent();
2626
const expectedLinkedinProfile = createMockedLinkedinProfile();
2727

28-
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue(expectedLinkedinProfile);
28+
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
29+
linkedinProfile: expectedLinkedinProfile,
30+
isEmptyProfile: false
31+
});
2932

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

@@ -58,7 +61,10 @@ describe('Linkedin lambda handler', () => {
5861
const expectedErrorString =
5962
'[LinkedinProfileResponseMapper] MAC Validation failed: ["property: mac.aboutMe.profile.title errors: title should not be empty"]';
6063

61-
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue(expectedLinkedinProfile);
64+
linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({
65+
linkedinProfile: expectedLinkedinProfile,
66+
isEmptyProfile: false
67+
});
6268

6369
await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString));
6470
});

src/index.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,27 @@ import { LinkedinProfileService } from './services/linkedin-profile.service';
77
import { Environment } from './util/environment';
88
import { logger } from './util/logger';
99

10-
export const handler: Handler = async (event: SQSEvent): Promise<LinkedinProfileResponse> => {
10+
export const handler: Handler = async (event: SQSEvent): Promise<LinkedinProfileResponse | undefined> => {
1111
try {
1212
const linkedinProfileRequest = LinkedinProfileRequestMapper.toDomain(event);
1313
Environment.setupEnvironment(linkedinProfileRequest);
14+
logger.info(`⌛️ [handler] Starting Linkedin profile request for linkedinUrl: ${linkedinProfileRequest.linkedinUrl}`, linkedinProfileRequest);
1415

15-
const linkedinProfile = await new LinkedinProfileService().getLinkedinProfile(linkedinProfileRequest.profileApiToken);
16-
const linkedinProfileResponse = LinkedinProfileResponseMapper.toResponse(linkedinProfile);
16+
const { linkedinProfile, isEmptyProfile } = await new LinkedinProfileService().getLinkedinProfile(linkedinProfileRequest.profileApiToken);
1717

18-
logger.info(`✅ [handler] Linkedin profile response with MAC: ${JSON.stringify(linkedinProfileResponse.mac)}`);
18+
if (isEmptyProfile) {
19+
logger.warn(`👻 [handler] Linkedin profile is not synced for linkedinUrl: ${linkedinProfileRequest.linkedinUrl}`, linkedinProfileRequest);
20+
// TODO: send response to SQS queue again if no max retries
21+
return undefined;
22+
}
1923

20-
// TODO: send response to SQS queue
24+
const linkedinProfileResponse = LinkedinProfileResponseMapper.toResponse(linkedinProfile);
25+
logger.info(`✅ [handler] Linkedin profile response with MAC: ${JSON.stringify(linkedinProfileResponse.mac)}`, linkedinProfileRequest);
26+
// TODO: send response to result SQS queue
2127

2228
return linkedinProfileResponse;
2329
} catch (error) {
30+
logger.error(`❌ [handler] Error processing Linkedin profile request: ${error}`, error);
2431
throw error;
2532
}
2633
};

src/mappers/linkedin-profile.response.mapper.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ export class LinkedinProfileResponseMapper {
6464

6565
const role = new LinkedinProfileResponseMacJobRole();
6666
role.name = position.Title || '';
67-
role.startDate = DateUtilities.toIsoDate(position['Started On'], 'MMM yyyy') || new Date().toISOString();
68-
role.finishDate = position['Finished On'] ? DateUtilities.toIsoDate(position['Finished On'], 'MMM yyyy') : undefined;
67+
role.startDate = DateUtilities.toIsoDate(position['Started On']);
68+
role.finishDate = position['Finished On'] ? DateUtilities.toIsoDate(position['Finished On']) : undefined;
6969

7070
const challenge = new LinkedinProfileResponseMacExperienceJobChallenge();
7171
challenge.description = position.Description || '';
@@ -95,8 +95,8 @@ export class LinkedinProfileResponseMapper {
9595
const study = new LinkedinProfileResponseMacStudy();
9696
study.studyType = 'officialDegree';
9797
study.name = educationItem['Degree Name'] || educationItem['School Name'] || '';
98-
study.startDate = DateUtilities.toIsoDate(educationItem['Start Date'], 'yyyy') || new Date().toISOString();
99-
study.finishDate = educationItem['End Date'] ? DateUtilities.toIsoDate(educationItem['End Date'], 'yyyy') : undefined;
98+
study.startDate = DateUtilities.toIsoDate(educationItem['Start Date']);
99+
study.finishDate = educationItem['End Date'] ? DateUtilities.toIsoDate(educationItem['End Date']) : undefined;
100100
study.degreeAchieved = !!educationItem['End Date'];
101101
study.description = educationItem['Degree Name'] || educationItem['School Name'];
102102
if (educationItem['School Name']) {
@@ -111,7 +111,6 @@ export class LinkedinProfileResponseMapper {
111111
private static validate(response: LinkedinProfileResponse): void {
112112
const errors = validateSync(response);
113113
if (errors.length > 0) {
114-
console.log(JSON.stringify(response.mac, null, 2));
115114
logger.error(`[LinkedinProfileResponseMapper] MAC Validation failed: ${JSON.stringify(errors)}`, { errors, response });
116115
const formattedErrors = ValidationUtilities.formatErrors(errors);
117116
throw new Error(`[LinkedinProfileResponseMapper] MAC Validation failed: ${JSON.stringify(formattedErrors)}`);

src/services/linkedin-api.client.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('A linkedin api client', () => {
2929
return undefined;
3030
});
3131

32-
const education = await client.fetchProfileDomainData('fake_token', 'EDUCATION');
32+
const education = await client.fetchProfileDomainData('fake_token', 'EDUCATION', 'ARRAY');
3333

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

src/services/linkedin-api.client.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,15 @@ import axios, { AxiosError } from 'axios';
22
import { logger } from '../util/logger';
33

44
export type LinkedinDomain = 'PROFILE' | 'SKILLS' | 'POSITIONS' | 'EDUCATION';
5+
export type LinkedinDomainResponseType = 'OBJECT' | 'ARRAY';
56

67
export class LinkedinAPIClient {
78
public constructor() {}
89

9-
/* TODO:
10-
- refactor to not check for domain type
11-
- improve error handling: detect not provided data
12-
*/
13-
public async fetchProfileDomainData<A>(token: string, domain: LinkedinDomain): Promise<A> {
10+
public async fetchProfileDomainData<A>(token: string, domain: LinkedinDomain, responseType: LinkedinDomainResponseType): Promise<A> {
1411
const url = `https://api.linkedin.com/rest/memberSnapshotData?q=criteria&domain=${domain}`;
15-
const isResponseArray = domain !== 'PROFILE';
12+
const isArrayData = responseType === 'ARRAY';
13+
1614
logger.debug(`🔍 [LinkedinAPIClient] Fetching ${domain} domain profile data ...`);
1715
try {
1816
const headers = {
@@ -22,10 +20,10 @@ export class LinkedinAPIClient {
2220

2321
const response = await axios.get(url, { headers });
2422

25-
const data = isResponseArray ? response.data.elements[0].snapshotData : response.data.elements[0].snapshotData[0];
23+
const data = isArrayData ? response.data.elements[0].snapshotData : response.data.elements[0].snapshotData[0];
2624
return data;
2725
} catch (error: unknown) {
28-
if (error instanceof AxiosError && error.response?.status === 404) return (isResponseArray ? [] : {}) as A;
26+
if (error instanceof AxiosError && error.response?.status === 404) return (isArrayData ? [] : {}) as A;
2927
if (error instanceof AxiosError) {
3028
const responseData = JSON.stringify(error.response?.data);
3129
logger.error(`🚨 [LinkedinAPIClient] Error fetching ${domain} profile data: ${responseData}`, error.stack);

src/services/linkedin-profile.service.spec.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,23 @@ describe('A linkedin profile service', () => {
1919
it('should get linkedin profile using client', async () => {
2020
const expectedProfile = createMockedLinkedinProfile();
2121

22-
client.fetchProfileDomainData.calledWith('fake_token', 'PROFILE').mockResolvedValue(expectedProfile.profile);
23-
client.fetchProfileDomainData.calledWith('fake_token', 'SKILLS').mockResolvedValue(expectedProfile.skills);
24-
client.fetchProfileDomainData.calledWith('fake_token', 'POSITIONS').mockResolvedValue(expectedProfile.positions);
25-
client.fetchProfileDomainData.calledWith('fake_token', 'EDUCATION').mockResolvedValue(expectedProfile.education);
22+
client.fetchProfileDomainData.calledWith('fake_token', 'PROFILE', 'OBJECT').mockResolvedValue(expectedProfile.profile);
23+
client.fetchProfileDomainData.calledWith('fake_token', 'SKILLS', 'ARRAY').mockResolvedValue(expectedProfile.skills);
24+
client.fetchProfileDomainData.calledWith('fake_token', 'POSITIONS', 'ARRAY').mockResolvedValue(expectedProfile.positions);
25+
client.fetchProfileDomainData.calledWith('fake_token', 'EDUCATION', 'ARRAY').mockResolvedValue(expectedProfile.education);
2626

27-
const profile = await service.getLinkedinProfile('fake_token');
27+
const { linkedinProfile, isEmptyProfile } = await service.getLinkedinProfile('fake_token');
28+
expect(linkedinProfile).toEqual(expectedProfile);
29+
expect(isEmptyProfile).toBe(false);
30+
});
31+
32+
it('should return empty profile flag', async () => {
33+
client.fetchProfileDomainData.calledWith('fake_token', 'PROFILE', 'OBJECT').mockResolvedValue({});
34+
client.fetchProfileDomainData.calledWith('fake_token', 'SKILLS', 'ARRAY').mockResolvedValue([]);
35+
client.fetchProfileDomainData.calledWith('fake_token', 'POSITIONS', 'ARRAY').mockResolvedValue([]);
36+
client.fetchProfileDomainData.calledWith('fake_token', 'EDUCATION', 'ARRAY').mockResolvedValue([]);
2837

29-
expect(profile).toEqual(expectedProfile);
38+
const { isEmptyProfile } = await service.getLinkedinProfile('fake_token');
39+
expect(isEmptyProfile).toBe(true);
3040
});
3141
});

src/services/linkedin-profile.service.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,23 @@ export class LinkedinProfileService {
1313

1414
public constructor() {}
1515

16-
public async getLinkedinProfile(token: string): Promise<LinkedinProfile> {
17-
const profile = await this.client.fetchProfileDomainData<LinkedinProfileProfile>(token, 'PROFILE');
18-
const skills = await this.client.fetchProfileDomainData<LinkedinProfileSkill[]>(token, 'SKILLS');
19-
const positions = await this.client.fetchProfileDomainData<LinkedinProfilePosition[]>(token, 'POSITIONS');
20-
const education = await this.client.fetchProfileDomainData<LinkedinProfileEducation[]>(token, 'EDUCATION');
16+
public async getLinkedinProfile(token: string): Promise<{ linkedinProfile: LinkedinProfile; isEmptyProfile: boolean }> {
17+
const profile = await this.client.fetchProfileDomainData<LinkedinProfileProfile>(token, 'PROFILE', 'OBJECT');
18+
const skills = await this.client.fetchProfileDomainData<LinkedinProfileSkill[]>(token, 'SKILLS', 'ARRAY');
19+
const positions = await this.client.fetchProfileDomainData<LinkedinProfilePosition[]>(token, 'POSITIONS', 'ARRAY');
20+
const education = await this.client.fetchProfileDomainData<LinkedinProfileEducation[]>(token, 'EDUCATION', 'ARRAY');
2121

2222
const linkedinProfile = { profile, skills, positions, education };
23+
const isEmptyProfile = this.isEmptyProfile(profile);
24+
logger.debug(`🧐 [LinkedinProfileService] Linkedin profile retrieved: ${JSON.stringify(linkedinProfile)}`);
2325

24-
logger.debug(`🧐 [LinkedinProfileService] Linkedin profile retrieved: ${JSON.stringify(linkedinProfile)}`, { linkedinProfile });
25-
return linkedinProfile;
26+
return { linkedinProfile, isEmptyProfile };
27+
}
28+
29+
// --- 🔐 Private methods
30+
31+
private isEmptyProfile(profile: LinkedinProfileProfile): boolean {
32+
const json = JSON.stringify(profile);
33+
return json === '{}';
2634
}
2735
}

src/util/date.spec.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,45 @@
11
import { DateUtilities } from './date';
22

3-
describe('date utities', () => {
3+
describe('date utilities', () => {
4+
beforeAll(() => {
5+
jest.useFakeTimers().setSystemTime(new Date('2023-10-01'));
6+
});
7+
8+
afterAll(() => {
9+
jest.useRealTimers();
10+
});
11+
412
describe('when calling toIsoDate', () => {
5-
it('should return the date in ISO format with `MMM yyyy` format', () => {
13+
it('should correctly format a date in `MMM yyyy` format to ISO format', () => {
614
const date = 'Mar 2026';
715
const expected = '2026-03-01';
816

9-
const result = DateUtilities.toIsoDate(date, 'MMM yyyy');
17+
const result = DateUtilities.toIsoDate(date);
1018
expect(result).toBe(expected);
1119
});
1220

13-
it('should return the date in ISO format with `yyyy` format', () => {
21+
it('should correctly format a date in `yyyy` format to ISO format', () => {
1422
const date = '2026';
1523
const expected = '2026-01-01';
1624

17-
const result = DateUtilities.toIsoDate(date, 'yyyy');
25+
const result = DateUtilities.toIsoDate(date);
1826
expect(result).toBe(expected);
1927
});
2028

21-
it('should return undefined when the date is not provided', () => {
29+
it("should return today's date in ISO format when the date is not provided", () => {
2230
const date = undefined;
31+
const expected = '2023-10-01';
2332

24-
const result = DateUtilities.toIsoDate(date, 'MMM yyyy');
25-
expect(result).toBeUndefined();
33+
const result = DateUtilities.toIsoDate(date);
34+
expect(result).toBe(expected);
2635
});
2736

28-
it('should return undefined when the date is not valid', () => {
37+
it("should return today's date in ISO format when the date is invalid", () => {
2938
const date = 'invalid date';
39+
const expected = '2023-10-01';
3040

31-
const result = DateUtilities.toIsoDate(date, 'MMM yyyy');
32-
expect(result).toBeUndefined();
41+
const result = DateUtilities.toIsoDate(date);
42+
expect(result).toBe(expected);
3343
});
3444
});
3545
});

src/util/date.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
import { format, parse } from 'date-fns';
22
import { enUS } from 'date-fns/locale';
3+
import { logger } from './logger';
34

45
export class DateUtilities {
5-
public static toIsoDate(date: string | undefined, dateFormat: 'MMM yyyy' | 'yyyy'): string | undefined {
6-
if (!date) return undefined;
7-
8-
try {
9-
const parsedDate = parse(date, dateFormat, new Date(), { locale: enUS });
10-
return format(parsedDate, 'yyyy-MM-dd');
11-
} catch {
12-
return undefined;
6+
public static toIsoDate(date: string | undefined): string {
7+
const dateFormats = ['MMM yyyy', 'yyyy'];
8+
const now = format(new Date(), 'yyyy-MM-dd');
9+
10+
if (!date) {
11+
logger.warn(`[DateUtilities] Date is not provided, so returning today's date`);
12+
return now;
13+
}
14+
15+
for (const dateFormat of dateFormats) {
16+
try {
17+
const parsedDate = parse(date, dateFormat, new Date(), { locale: enUS });
18+
if (!isNaN(parsedDate.getTime())) return format(parsedDate, 'yyyy-MM-dd');
19+
} catch {
20+
continue;
21+
}
1322
}
23+
24+
logger.warn(`[DateUtilities] Could not parse date: ${date}, so returning today's date`);
25+
return now;
1426
}
1527
}

0 commit comments

Comments
 (0)