feat(api): POST /ms-trophy-challenge-completed (#51808)

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2023-11-03 10:00:55 +01:00
committed by GitHub
parent 6163330b70
commit 135812162e
8 changed files with 637 additions and 5 deletions
+9
View File
@@ -109,3 +109,12 @@ export async function seedExam(): Promise<void> {
}
});
}
export function createFetchMock({ ok = true, body = {} } = {}) {
return jest.fn().mockResolvedValue(
Promise.resolve({
ok,
json: () => Promise.resolve(body)
})
);
}
+293 -2
View File
@@ -1,8 +1,13 @@
// Yes, putting this above the imports is a hack to get around the fact that
// jest.mock() must be called at the top level of the file.
const mockVerifyTrophyWithMicrosoft = jest.fn();
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { omit } from 'lodash';
import { challengeTypes } from '../../../shared/config/challenge-types';
import {
defaultUserId,
devLogin,
setupServer,
superRequest,
@@ -12,6 +17,18 @@ import {
import { completedTrophyChallenges } from '../../__mocks__/exam';
import { GeneratedAnswer } from '../utils/exam-types';
jest.mock('./helpers/challenge-helpers', () => {
const originalModule = jest.requireActual<
typeof import('./helpers/challenge-helpers')
>('./helpers/challenge-helpers');
return {
__esModule: true,
...originalModule,
verifyTrophyWithMicrosoft: mockVerifyTrophyWithMicrosoft
};
});
const isValidChallengeCompletionErrorMsg = {
type: 'error',
message: 'That does not appear to be a valid challenge submission.'
@@ -539,7 +556,8 @@ describe('challengeRoutes', () => {
completedDate: expect.any(Number)
});
// It should return an updated completedDate
// If a challenge has already been completed, it should return the
// original completedDate
expect(resUpdate.body.completedDate).not.toBe(
resOriginal.body.completedDate
);
@@ -1101,6 +1119,278 @@ describe('challengeRoutes', () => {
expect(response.statusCode).toBe(200);
});
});
describe('/ms-trophy-challenge-completed', () => {
const msUserId = 'abc123';
// Add Logic to C# Console Applications's id:
const trophyChallengeId = '647f882207d29547b3bee1c0';
// Create and Run Simple C# Console Applications's id:
const trophyChallengeId2 = '647f87dc07d29547b3bee1bf';
const nonTrophyChallengeId = 'bd7123c8c441eddfaeb5bdef';
const solutionUrl = `https://learn.microsoft.com/api/gamestatus/${msUserId}`;
const idIsMissingOrInvalid = {
type: 'error',
message: 'flash.ms.trophy.err-2'
} as const;
const userHasNotLinkedTheirAccount = {
type: 'error',
message: 'flash.ms.trophy.err-1'
} as const;
const unexpectedError = {
type: 'error',
message: 'flash.ms.trophy.err-5'
} as const;
describe('validation', () => {
test('POST rejects requests without valid ids', async () => {
const resNoId = await superRequest(
'/ms-trophy-challenge-completed',
{
method: 'POST',
setCookies
}
);
expect(resNoId.body).toStrictEqual(idIsMissingOrInvalid);
expect(resNoId.statusCode).toBe(400);
const resBadId = await superRequest(
'/ms-trophy-challenge-completed',
{
method: 'POST',
setCookies
}
).send({ id: nonTrophyChallengeId });
expect(resBadId.body).toStrictEqual(idIsMissingOrInvalid);
expect(resBadId.statusCode).toBe(400);
});
// TODO(Post-MVP): give a more specific error message
test('POST rejects requests without valid ObjectIDs', async () => {
const response = await superRequest(
'/ms-trophy-challenge-completed',
{
method: 'POST',
setCookies
}
).send({ id: 'not-a-valid-id' });
expect(response.body).toStrictEqual(idIsMissingOrInvalid);
expect(response.statusCode).toBe(400);
});
});
describe('handling', () => {
async function createMSUsernameRecord(msUsername: string) {
await fastifyTestInstance.prisma.msUsername.create({
data: {
msUsername,
ttl: 123,
userId: defaultUserId
}
});
}
afterEach(async () => {
await fastifyTestInstance.prisma.msUsername.deleteMany({
where: { userId: defaultUserId }
});
await fastifyTestInstance.prisma.user.updateMany({
where: { id: defaultUserId },
data: {
completedChallenges: [],
progressTimestamps: []
}
});
});
test('POST rejects requests if the user does not have a Microsoft username', async () => {
const res = await superRequest('/ms-trophy-challenge-completed', {
method: 'POST',
setCookies
}).send({ id: trophyChallengeId });
expect(res.body).toStrictEqual(userHasNotLinkedTheirAccount);
expect(res.statusCode).toBe(403);
});
test("POST rejects requests if Microsoft's api responds with an error", async () => {
const msUsername = 'ANRandom';
await createMSUsernameRecord(msUsername);
// This can be any error that the route can serialize. Other than
// that, the details do not matter, since whatever
// verifyTrophyWithMicrosoft returns will be returned by the route.
const verifyError = {
type: 'error',
message: 'flash.ms.profile.err',
variables: {
msUsername
}
};
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() =>
Promise.resolve(verifyError)
);
const res = await superRequest('/ms-trophy-challenge-completed', {
method: 'POST',
setCookies
}).send({ id: trophyChallengeId });
expect(res.body).toStrictEqual(verifyError);
expect(res.statusCode).toBe(403);
});
test('POST handles unexpected errors', async () => {
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => {
throw new Error('Network error');
});
const msUsername = 'ANRandom';
await createMSUsernameRecord(msUsername);
const res = await superRequest('/ms-trophy-challenge-completed', {
method: 'POST',
setCookies
}).send({ id: trophyChallengeId });
expect(res.body).toStrictEqual(unexpectedError);
expect(res.statusCode).toBe(500);
});
test('POST updates the user record with a new completed challenge', async () => {
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() =>
Promise.resolve({
type: 'success',
msGameStatusApiUrl: solutionUrl
})
);
const msUsername = 'ANRandom';
await createMSUsernameRecord(msUsername);
const now = Date.now();
const res = await superRequest('/ms-trophy-challenge-completed', {
method: 'POST',
setCookies
}).send({ id: trophyChallengeId });
const user =
await fastifyTestInstance.prisma.user.findUniqueOrThrow({
where: { id: defaultUserId }
});
const completedDate = user.completedChallenges[0]?.completedDate;
expect(res.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate
});
// TODO: use a custom matcher for thisu
expect(completedDate).toBeGreaterThan(now);
expect(completedDate).toBeLessThan(now + 1000);
expect(res.statusCode).toBe(200);
expect(user).toMatchObject({
completedChallenges: [
{
id: trophyChallengeId,
solution: solutionUrl,
completedDate: expect.any(Number)
}
]
});
});
it('POST correctly handles multiple requests', async () => {
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() =>
Promise.resolve({
type: 'success',
msGameStatusApiUrl: solutionUrl
})
);
const msUsername = 'ANRandom';
await createMSUsernameRecord(msUsername);
const resOne = await superRequest(
'/ms-trophy-challenge-completed',
{
method: 'POST',
setCookies
}
).send({ id: trophyChallengeId });
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() =>
Promise.resolve({
type: 'success',
msGameStatusApiUrl: solutionUrl
})
);
const resTwo = await superRequest(
'/ms-trophy-challenge-completed',
{
method: 'POST',
setCookies
}
).send({ id: trophyChallengeId2 });
// sending the second trophy challenge again should not change
// anything
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() =>
Promise.resolve({
type: 'success',
msGameStatusApiUrl: solutionUrl
})
);
const resUpdate = await superRequest(
'/ms-trophy-challenge-completed',
{
method: 'POST',
setCookies
}
).send({ id: trophyChallengeId2 });
const { completedChallenges, progressTimestamps } =
await fastifyTestInstance.prisma.user.findUniqueOrThrow({
where: { id: defaultUserId }
});
expect(completedChallenges).toHaveLength(2);
expect(completedChallenges).toStrictEqual(
expect.arrayContaining([
expect.objectContaining({
id: trophyChallengeId,
solution: solutionUrl,
completedDate: resOne.body.completedDate
}),
expect.objectContaining({
id: trophyChallengeId2,
solution: solutionUrl,
completedDate: resTwo.body.completedDate
})
])
);
const expectedProgressTimestamps = completedChallenges.map(
challenge => challenge.completedDate
);
expect(progressTimestamps).toStrictEqual(
expectedProgressTimestamps
);
expect(resUpdate.body).toStrictEqual({
alreadyCompleted: true,
points: 2,
completedDate: expect.any(Number)
});
// If a challenge has already been completed, it should return the
// original completedDate
expect(resUpdate.body.completedDate).toBe(
resTwo.body.completedDate
);
expect(resUpdate.statusCode).toBe(200);
});
});
});
});
});
@@ -1119,7 +1409,8 @@ describe('challengeRoutes', () => {
{ path: '/backend-challenge-completed', method: 'POST' },
{ path: '/modern-challenge-completed', method: 'POST' },
{ path: '/save-challenge', method: 'POST' },
{ path: '/exam/647e22d18acb466c97ccbef8', method: 'GET' }
{ path: '/exam/647e22d18acb466c97ccbef8', method: 'GET' },
{ path: '/ms-trophy-challenge-completed', method: 'POST' }
];
endpoints.forEach(({ path, method }) => {
+103 -2
View File
@@ -9,7 +9,8 @@ import {
multifileCertProjectIds,
updateUserChallengeData,
type CompletedChallenge,
saveUserChallengeData
saveUserChallengeData,
msTrophyChallenges
} from '../utils/common-challenge-functions';
import { JWT_SECRET } from '../utils/env';
import {
@@ -26,7 +27,8 @@ import { generateRandomExam } from '../utils/exam';
import {
canSubmitCodeRoadCertProject,
createProject,
updateProject
updateProject,
verifyTrophyWithMicrosoft
} from './helpers/challenge-helpers';
interface JwtPayload {
@@ -512,5 +514,104 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
}
);
fastify.post(
'/ms-trophy-challenge-completed',
{
schema: schemas.msTrophyChallengeCompleted,
errorHandler(error, request, reply) {
if (error.validation) {
void reply.code(400);
void reply.send({ type: 'error', message: 'flash.ms.trophy.err-2' });
} else {
fastify.errorHandler(error, request, reply);
}
}
},
async (req, reply) => {
try {
const challengeId = req.body.id;
const challenge = msTrophyChallenges.find(
challenge => challenge.id === challengeId
);
if (!challenge) {
return reply
.code(400)
.send({ type: 'error', message: 'flash.ms.trophy.err-2' });
}
const msUser = await fastify.prisma.msUsername.findFirst({
where: { userId: req.session.user.id }
});
if (!msUser || !msUser.msUsername) {
return reply
.code(403)
.send({ type: 'error', message: 'flash.ms.trophy.err-1' });
}
const { msUsername } = msUser;
// TODO: log error if msTrophyId not found?
const msTrophyId = challenge.msTrophyId ?? '';
const msTrophyStatus = await verifyTrophyWithMicrosoft({
msUsername,
msTrophyId
});
if (msTrophyStatus.type === 'error') {
return reply.code(403).send(msTrophyStatus);
}
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id },
select: { completedChallenges: true, progressTimestamps: true }
});
const { completedChallenges } = user;
const progressTimestamps =
user.progressTimestamps as ProgressTimestamp[];
const oldChallenge = completedChallenges.find(
({ id }) => id === challengeId
);
const alreadyCompleted = !!oldChallenge;
const completedDate = alreadyCompleted
? oldChallenge.completedDate
: Date.now();
if (!alreadyCompleted) {
const newChallenge = {
id: challengeId,
completedDate,
solution: msTrophyStatus.msGameStatusApiUrl
};
await fastify.prisma.user.update({
where: { id: req.session.user.id },
data: {
completedChallenges: {
push: newChallenge
},
progressTimestamps: [...progressTimestamps, completedDate]
}
});
}
return {
alreadyCompleted,
points: getPoints(progressTimestamps) + (alreadyCompleted ? 0 : 1),
completedDate
};
} catch (error) {
fastify.log.error(error);
void reply.code(500);
return {
type: 'error',
message: 'flash.ms.trophy.err-5'
} as const;
}
}
);
done();
};
@@ -3,7 +3,11 @@ import type {
CompletedChallenge
} from '@prisma/client';
import { canSubmitCodeRoadCertProject } from './challenge-helpers';
import { createFetchMock } from '../../../jest.utils';
import {
canSubmitCodeRoadCertProject,
verifyTrophyWithMicrosoft
} from './challenge-helpers';
const id = 'abc';
@@ -68,4 +72,100 @@ describe('Challenge Helpers', () => {
).toBe(false);
});
});
describe('verifyTrophyWithMicrosoft', () => {
const userId = 'abc123';
const msUsername = 'ANRandom';
const msTrophyId = 'learn.wwl.get-started-c-sharp-part-3.trophy';
const verifyData = { msUsername, msTrophyId };
const gamestatusUrl = `https://learn.microsoft.com/api/gamestatus/${userId}`;
afterEach(() => jest.clearAllMocks());
test("handles failure to reach Microsoft's profile api", async () => {
const notOk = createFetchMock({ ok: false });
jest.spyOn(globalThis, 'fetch').mockImplementation(notOk);
const verification = await verifyTrophyWithMicrosoft(verifyData);
expect(verification).toEqual({
type: 'error',
message: 'flash.ms.profile.err',
variables: {
msUsername
}
});
});
test("handles failure to reach Microsoft's gamestatus api", async () => {
const fetchProfile = createFetchMock({ body: { userId } });
const fetchGameStatus = createFetchMock({ ok: false });
jest
.spyOn(globalThis, 'fetch')
.mockImplementationOnce(fetchProfile)
.mockImplementationOnce(fetchGameStatus);
const verification = await verifyTrophyWithMicrosoft(verifyData);
expect(verification).toEqual({
type: 'error',
message: 'flash.ms.trophy.err-3'
});
});
test('handles the case where the user has no achievements', async () => {
const fetchProfile = createFetchMock({ body: { userId } });
const fetchGameStatus = createFetchMock({ body: { achievements: [] } });
jest
.spyOn(globalThis, 'fetch')
.mockImplementationOnce(fetchProfile)
.mockImplementationOnce(fetchGameStatus);
const verification = await verifyTrophyWithMicrosoft(verifyData);
expect(verification).toEqual({
type: 'error',
message: 'flash.ms.trophy.err-6'
});
});
test("handles failure to find the trophy in the user's achievements", async () => {
const fetchProfile = createFetchMock({ body: { userId } });
const fetchGameStatus = createFetchMock({
body: { achievements: [{ awardUid: 'fake-id' }] }
});
jest
.spyOn(globalThis, 'fetch')
.mockImplementationOnce(fetchProfile)
.mockImplementationOnce(fetchGameStatus);
const verification = await verifyTrophyWithMicrosoft(verifyData);
expect(verification).toEqual({
type: 'error',
message: 'flash.ms.trophy.err-4',
variables: {
msUsername
}
});
});
test('returns msGameStatusApiUrl on success', async () => {
const fetchProfile = createFetchMock({ body: { userId } });
const fetchGameStatus = createFetchMock({
body: { achievements: [{ awardUid: msTrophyId }] }
});
jest
.spyOn(globalThis, 'fetch')
.mockImplementationOnce(fetchProfile)
.mockImplementationOnce(fetchGameStatus);
const verification = await verifyTrophyWithMicrosoft(verifyData);
expect(verification).toEqual({
type: 'success',
msGameStatusApiUrl: gamestatusUrl
});
});
});
});
@@ -58,3 +58,81 @@ export const createProject = (
partiallyCompletedChallenges: { deleteMany: { where: { id } } },
progressTimestamps: [...progressTimestamps, newChallenge.completedDate]
});
async function getMSProfile(msUsername: string) {
const profileError = {
type: 'error',
message: 'flash.ms.profile.err',
variables: {
msUsername
}
} as const;
const msProfileApi = `https://learn.microsoft.com/api/profiles/${msUsername}`;
const msProfileApiRes = await fetch(msProfileApi);
if (!msProfileApiRes.ok) return profileError;
const { userId } = (await msProfileApiRes.json()) as {
userId: string;
};
return userId ? ({ type: 'success', userId } as const) : profileError;
}
/**
* Handles all communication with the Microsoft Learn APIs.
*
* @param requestData The data needed by the Microsoft Learn APIs.
* @param requestData.msUsername The Microsoft username used to get the profile.
* @param requestData.msTrophyId The Microsoft trophy ID to verify.
* @returns An object with 'type' of success|error and information about the success or failure.
*/
export async function verifyTrophyWithMicrosoft({
msUsername,
msTrophyId
}: {
msUsername: string;
msTrophyId: string;
}) {
const msProfile = await getMSProfile(msUsername);
if (msProfile.type === 'error') return msProfile;
const msGameStatusApiUrl = `https://learn.microsoft.com/api/gamestatus/${msProfile.userId}`;
const msGameStatusApiRes = await fetch(msGameStatusApiUrl);
if (!msGameStatusApiRes.ok) {
return {
type: 'error',
message: 'flash.ms.trophy.err-3'
} as const;
}
const { achievements } = (await msGameStatusApiRes.json()) as {
achievements?: { awardUid: string }[];
};
if (!achievements?.length)
return {
type: 'error',
message: 'flash.ms.trophy.err-6'
} as const;
const earnedTrophy = achievements?.some(a => a.awardUid === msTrophyId);
if (earnedTrophy) {
return {
type: 'success',
msGameStatusApiUrl
} as const;
} else {
return {
type: 'error',
message: 'flash.ms.trophy.err-4',
variables: {
msUsername
}
} as const;
}
}
+48
View File
@@ -580,6 +580,54 @@ export const schemas = {
})
}
},
msTrophyChallengeCompleted: {
body: Type.Object({
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 })
}),
response: {
200: Type.Object({
completedDate: Type.Number(),
points: Type.Number(),
alreadyCompleted: Type.Boolean()
}),
400: Type.Object({
type: Type.Literal('error'),
message: Type.Literal('flash.ms.trophy.err-2')
}),
403: Type.Union([
Type.Object({
type: Type.Literal('error'),
message: Type.Literal('flash.ms.trophy.err-1')
}),
Type.Object({
type: Type.Literal('error'),
message: Type.Literal('flash.ms.trophy.err-3')
}),
Type.Object({
type: Type.Literal('error'),
message: Type.Literal('flash.ms.trophy.err-4'),
variables: Type.Object({
msUsername: Type.String()
})
}),
Type.Object({
type: Type.Literal('error'),
message: Type.Literal('flash.ms.trophy.err-6')
}),
Type.Object({
type: Type.Literal('error'),
message: Type.Literal('flash.ms.profile.err'),
variables: Type.Object({
msUsername: Type.String()
})
})
]),
500: Type.Object({
type: Type.Literal('error'),
message: Type.Literal('flash.ms.trophy.err-5')
})
}
},
saveChallenge: {
body: saveChallengeBody,
response: {
@@ -16,6 +16,10 @@ export const multifileCertProjectIds = getChallenges()
.filter(c => c.challengeType === challengeTypes.multifileCertProject)
.map(c => c.id);
export const msTrophyChallenges = getChallenges()
.filter(challenge => challenge.challengeType === challengeTypes.msTrophy)
.map(({ id, msTrophyId }) => ({ id, msTrophyId }));
type SavedChallengeFile = {
key: string;
ext: string; // NOTE: This is Ext type in client
+1
View File
@@ -14,6 +14,7 @@ interface Block {
tests?: { id?: string }[];
challengeType: number;
url?: string;
msTrophyId?: string;
}[];
}