fix: re-revert the API decoupling (#41263)

* fix(api): decouple api from curriculum

This reverts commit 8f0e441644 and
introduces the implementations from #40703.

* fix(gitpod): add curriculum build to GitPod

This reverts commit 706d70f58d and
introduces implementations from #41234.

* docs: update DevOps manual for api change (#41259)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Mrugesh Mohapatra
2021-02-25 12:02:35 -08:00
committed by GitHub
co-authored by Oliver Eyton-Williams
parent 31bdea63a2
commit b5f4754e2a
172 changed files with 381 additions and 349 deletions
@@ -0,0 +1,3 @@
## Test scripts for the boot directory
These files cannot be co-located with the files under test due to the auto-discovery the loopback-boot employs.
@@ -0,0 +1,12 @@
/* global it expect */
import { getFallbackFrontEndDate } from '../boot/certificate';
import { fullStackChallenges } from './fixtures';
describe('boot/certificate', () => {
describe('getFallbackFrontEndDate', () => {
it('should return the date of the latest completed challenge', () => {
expect(getFallbackFrontEndDate(fullStackChallenges)).toBe(1685210952511);
});
});
});
@@ -0,0 +1,396 @@
/* global describe xdescribe it expect */
import { first, find } from 'lodash';
import sinon from 'sinon';
import { mockReq, mockRes } from 'sinon-express-mock';
import {
buildUserUpdate,
buildChallengeUrl,
createChallengeUrlResolver,
createRedirectToCurrentChallenge,
getFirstChallenge,
isValidChallengeCompletion
} from '../boot/challenge';
import {
firstChallengeUrl,
requestedChallengeUrl,
mockAllChallenges,
mockChallenge,
mockUser,
mockGetFirstChallenge,
mockCompletedChallenge,
mockCompletedChallenges
} from './fixtures';
describe('boot/challenge', () => {
xdescribe('backendChallengeCompleted', () => {});
describe('buildUserUpdate', () => {
it('returns an Object with a nested "completedChallenges" property', () => {
const result = buildUserUpdate(
mockUser,
'123abc',
mockCompletedChallenge,
'UTC'
);
expect(result).toHaveProperty('updateData.$set.completedChallenges');
});
// eslint-disable-next-line max-len
it('preserves file contents if the completed challenge is a JS Project', () => {
const jsChallengeId = 'aa2e6f85cab2ab736c9a9b24';
const completedChallenge = {
...mockCompletedChallenge,
completedDate: Date.now(),
id: jsChallengeId
};
const result = buildUserUpdate(
mockUser,
jsChallengeId,
completedChallenge,
'UTC'
);
const firstCompletedChallenge = first(
result.updateData.$set.completedChallenges
);
expect(firstCompletedChallenge).toEqual(completedChallenge);
});
it('preserves the original completed date of a challenge', () => {
const completedChallengeId = 'aaa48de84e1ecc7c742e1124';
const completedChallenge = {
...mockCompletedChallenge,
completedDate: Date.now(),
id: completedChallengeId
};
const originalCompletion = find(
mockCompletedChallenges,
x => x.id === completedChallengeId
).completedDate;
const result = buildUserUpdate(
mockUser,
completedChallengeId,
completedChallenge,
'UTC'
);
const firstCompletedChallenge = first(
result.updateData.$set.completedChallenges
);
expect(firstCompletedChallenge.completedDate).toEqual(originalCompletion);
});
// eslint-disable-next-line max-len
it('does not attempt to update progressTimestamps for a previously completed challenge', () => {
const completedChallengeId = 'aaa48de84e1ecc7c742e1124';
const completedChallenge = {
...mockCompletedChallenge,
completedDate: Date.now(),
id: completedChallengeId
};
const { updateData } = buildUserUpdate(
mockUser,
completedChallengeId,
completedChallenge,
'UTC'
);
const hasProgressTimestamps =
'$push' in updateData && 'progressTimestamps' in updateData.$push;
expect(hasProgressTimestamps).toBe(false);
});
// eslint-disable-next-line max-len
it('provides a progressTimestamps update for new challenge completion', () => {
expect.assertions(2);
const { updateData } = buildUserUpdate(
mockUser,
'123abc',
mockCompletedChallenge,
'UTC'
);
expect(updateData).toHaveProperty('$push');
expect(updateData.$push).toHaveProperty('progressTimestamps');
});
it('removes repeat completions from the completedChallenges array', () => {
const completedChallengeId = 'aaa48de84e1ecc7c742e1124';
const completedChallenge = {
...mockCompletedChallenge,
completedDate: Date.now(),
id: completedChallengeId
};
const {
updateData: {
$set: { completedChallenges }
}
} = buildUserUpdate(
mockUser,
completedChallengeId,
completedChallenge,
'UTC'
);
expect(completedChallenges.length).toEqual(
mockCompletedChallenges.length
);
});
// eslint-disable-next-line max-len
it('adds newly completed challenges to the completedChallenges array', () => {
const {
updateData: {
$set: { completedChallenges }
}
} = buildUserUpdate(mockUser, '123abc', mockCompletedChallenge, 'UTC');
expect(completedChallenges.length).toEqual(
mockCompletedChallenges.length + 1
);
});
});
describe('buildChallengeUrl', () => {
it('resolves the correct Url for the provided challenge', () => {
const result = buildChallengeUrl(mockChallenge);
expect(result).toEqual(requestedChallengeUrl);
});
});
describe('challengeUrlResolver', () => {
it('resolves to the first challenge url by default', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge
}
);
return challengeUrlResolver().then(url => {
expect(url).toEqual(firstChallengeUrl);
});
}, 10000);
// eslint-disable-next-line max-len
it('returns the first challenge url if the provided id does not relate to a challenge', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge
}
);
return challengeUrlResolver('not-a-real-challenge').then(url => {
expect(url).toEqual(firstChallengeUrl);
});
});
it('resolves the correct url for the requested challenge', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge
}
);
return challengeUrlResolver('123abc').then(url => {
expect(url).toEqual(requestedChallengeUrl);
});
});
});
describe('getFirstChallenge', () => {
it('returns the correct challenge url from the model', async () => {
const result = await getFirstChallenge(mockAllChallenges);
expect(result).toEqual(firstChallengeUrl);
});
it('returns the learn base if no challenges found', async () => {
const result = await getFirstChallenge([]);
expect(result).toEqual('/learn');
});
});
describe('isValidChallengeCompletion', () => {
const validObjectId = '5c716d1801013c3ce3aa23e6';
it('declares a 403 for an invalid id in the body', () => {
expect.assertions(3);
const req = mockReq({
body: { id: 'not-a-real-id' }
});
const res = mockRes();
const next = sinon.spy();
isValidChallengeCompletion(req, res, next);
expect(res.status.called).toBe(true);
expect(res.status.getCall(0).args[0]).toBe(403);
expect(next.called).toBe(false);
});
it('declares a 403 for an invalid challengeType in the body', () => {
expect.assertions(3);
const req = mockReq({
body: { id: validObjectId, challengeType: 'ponyfoo' }
});
const res = mockRes();
const next = sinon.spy();
isValidChallengeCompletion(req, res, next);
expect(res.status.called).toBe(true);
expect(res.status.getCall(0).args[0]).toBe(403);
expect(next.called).toBe(false);
});
it('declares a 403 for an invalid solution in the body', () => {
expect.assertions(3);
const req = mockReq({
body: {
id: validObjectId,
challengeType: '1',
solution: 'https://not-a-url'
}
});
const res = mockRes();
const next = sinon.spy();
isValidChallengeCompletion(req, res, next);
expect(res.status.called).toBe(true);
expect(res.status.getCall(0).args[0]).toBe(403);
expect(next.called).toBe(false);
});
it('calls next if the body is valid', () => {
const req = mockReq({
body: {
id: validObjectId,
challengeType: '1',
solution: 'https://www.freecodecamp.org'
}
});
const res = mockRes();
const next = sinon.spy();
isValidChallengeCompletion(req, res, next);
expect(next.called).toBe(true);
});
it('calls next if only the id is provided', () => {
const req = mockReq({
body: {
id: validObjectId
}
});
const res = mockRes();
const next = sinon.spy();
isValidChallengeCompletion(req, res, next);
expect(next.called).toBe(true);
});
it('can handle an "int" challengeType', () => {
const req = mockReq({
body: {
id: validObjectId,
challengeType: 1
}
});
const res = mockRes();
const next = sinon.spy();
isValidChallengeCompletion(req, res, next);
expect(next.called).toBe(true);
});
});
xdescribe('modernChallengeCompleted', () => {});
xdescribe('projectCompleted', () => {});
describe('redirectToCurrentChallenge', () => {
const mockHomeLocation = 'https://www.example.com';
const mockLearnUrl = `${mockHomeLocation}/learn`;
const mockgetParamsFromReq = () => ({
returnTo: mockLearnUrl,
origin: mockHomeLocation,
pathPrefix: ''
});
const mockNormalizeParams = params => params;
it('redirects to the learn base url for non-users', async done => {
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
() => {},
mockNormalizeParams,
mockgetParamsFromReq
);
const req = mockReq();
const res = mockRes();
const next = sinon.spy();
await redirectToCurrentChallenge(req, res, next);
expect(res.redirect.calledWith(mockLearnUrl));
done();
});
// eslint-disable-next-line max-len
it('redirects to the url provided by the challengeUrlResolver', async done => {
const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge
}
);
const expectedUrl = `${mockHomeLocation}${requestedChallengeUrl}`;
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver,
mockNormalizeParams,
mockgetParamsFromReq
);
const req = mockReq({
user: mockUser
});
const res = mockRes();
const next = sinon.spy();
await redirectToCurrentChallenge(req, res, next);
expect(res.redirect.calledWith(expectedUrl)).toBe(true);
done();
});
// eslint-disable-next-line max-len
it('redirects to the first challenge for users without a currentChallengeId', async done => {
const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge
}
);
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver,
mockNormalizeParams,
mockgetParamsFromReq
);
const req = mockReq({
user: { ...mockUser, currentChallengeId: '' }
});
const res = mockRes();
const next = sinon.spy();
await redirectToCurrentChallenge(req, res, next);
const expectedUrl = `${mockHomeLocation}${firstChallengeUrl}`;
expect(res.redirect.calledWith(expectedUrl)).toBe(true);
done();
});
});
});
@@ -0,0 +1,209 @@
/* global jest*/
import { isEqual } from 'lodash';
import { isEmail } from 'validator';
export const firstChallengeUrl = '/learn/the/first/challenge';
export const requestedChallengeUrl = '/learn/my/actual/challenge';
export const mockChallenge = {
id: '123abc',
block: 'actual',
superBlock: 'my',
dashedName: 'challenge'
};
export const mockFirstChallenge = {
id: '456def',
block: 'first',
superBlock: 'the',
dashedName: 'challenge',
challengeOrder: 0,
superOrder: 1,
order: 0
};
export const mockCompletedChallenge = {
id: '890xyz',
challengeType: 0,
files: [
{
contents: 'file contents',
key: 'indexfile',
name: 'index',
path: 'index.file',
ext: 'file'
}
],
completedDate: Date.now()
};
export const mockCompletedChallenges = [
{
id: 'bd7123c8c441eddfaeb5bdef',
completedDate: 1538052380328.0
},
{
id: '587d7dbd367417b2b2512bb4',
completedDate: 1547472893032.0,
files: []
},
{
id: 'aaa48de84e1ecc7c742e1124',
completedDate: 1541678430790.0,
files: [
{
contents:
// eslint-disable-next-line max-len
"function palindrome(str) {\n const clean = str.replace(/[\\W_]/g, '').toLowerCase()\n const revStr = clean.split('').reverse().join('');\n return clean === revStr;\n}\n\n\n\npalindrome(\"eye\");\n",
ext: 'js',
path: 'index.js',
name: 'index',
key: 'indexjs'
}
]
},
{
id: '5a24c314108439a4d4036164',
completedDate: 1543845124143.0,
files: []
}
];
export const mockUserID = '5c7d892aff9777c8b1c1a95e';
export const createUserMockFn = jest.fn();
export const createDonationMockFn = jest.fn();
export const updateDonationAttr = jest.fn();
export const updateUserAttr = jest.fn();
export const mockUser = {
id: mockUserID,
username: 'camperbot',
currentChallengeId: '123abc',
email: 'donor@freecodecamp.com',
timezone: 'UTC',
completedChallenges: mockCompletedChallenges,
progressTimestamps: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
isDonating: true,
donationEmails: ['donor@freecodecamp.com', 'donor@freecodecamp.com'],
createDonation: donation => {
createDonationMockFn(donation);
return mockObservable;
},
updateAttributes: updateUserAttr
};
const mockObservable = {
toPromise: () => Promise.resolve('result')
};
export const mockDonation = {
id: '5e5f8eda5ed7be2b54e18718',
email: 'donor@freecodecamp.com',
provider: 'paypal',
amount: 500,
duration: 'month',
startDate: {
_when: '2018-11-01T00:00:00.000Z',
_date: '2018-11-01T00:00:00.000Z'
},
subscriptionId: 'I-BA1ATBNF8T3P',
userId: mockUserID,
updateAttributes: updateDonationAttr
};
export function createNewUserFromEmail(email) {
const newMockUser = mockUser;
newMockUser.email = email;
newMockUser.username = 'camberbot2';
newMockUser.ID = '5c7d892aff9888c8b1c1a95e';
return newMockUser;
}
export const mockApp = {
models: {
Donation: {
findOne(query, cb) {
return isEqual(query, matchSubscriptionIdQuery)
? cb(null, mockDonation)
: cb(Error('No Donation'));
}
},
User: {
findById(id, cb) {
if (id === mockUser.id) {
return cb(null, mockUser);
}
return cb(Error('No user'));
},
findOne(query, cb) {
if (isEqual(query, matchEmailQuery) || isEqual(query, matchUserIdQuery))
return cb(null, mockUser);
return cb(null, null);
},
create(query, cb) {
if (!isEmail(query.email)) return cb(new Error('email not valid'));
else if (query.email === mockUser.email)
return cb(new Error('user exist'));
createUserMockFn();
return Promise.resolve(createNewUserFromEmail(query.email));
}
}
}
};
export const mockAllChallenges = [mockFirstChallenge, mockChallenge];
export const mockGetFirstChallenge = () => firstChallengeUrl;
export const matchEmailQuery = {
where: { email: mockUser.email }
};
export const matchSubscriptionIdQuery = {
where: { subscriptionId: mockDonation.subscriptionId }
};
export const matchUserIdQuery = {
where: { id: mockUser.id }
};
export const firstChallengeQuery = {
// first challenge of the first block of the first superBlock
where: { challengeOrder: 0, superOrder: 1, order: 0 }
};
export const fullStackChallenges = [
{
completedDate: 1585210952511,
id: '5a553ca864b52e1d8bceea14',
challengeType: 7,
files: []
},
{
completedDate: 1585210952511,
id: '561add10cb82ac38a17513bc',
challengeType: 7,
files: []
},
{
completedDate: 1588665778679,
id: '561acd10cb82ac38a17513bc',
challengeType: 7,
files: []
},
{
completedDate: 1685210952511,
id: '561abd10cb81ac38a17513bc',
challengeType: 7,
files: []
},
{
completedDate: 1585210952511,
id: '561add10cb82ac38a17523bc',
challengeType: 7,
files: []
},
{
completedDate: 1588665778679,
id: '561add10cb82ac38a17213bc',
challengeType: 7,
files: []
}
];