mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): POST /ms-trophy-challenge-completed (#51808)
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
6163330b70
commit
135812162e
@@ -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)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,7 @@ interface Block {
|
||||
tests?: { id?: string }[];
|
||||
challengeType: number;
|
||||
url?: string;
|
||||
msTrophyId?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user