diff --git a/api/src/routes/challenge.test.ts b/api/src/routes/challenge.test.ts index 45469f0a4d6..eb4f2d4e92f 100644 --- a/api/src/routes/challenge.test.ts +++ b/api/src/routes/challenge.test.ts @@ -1032,7 +1032,7 @@ describe('challengeRoutes', () => { // 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 solutionUrl = `https://learn.microsoft.com/api/achievements/user/${msUserId}`; const idIsMissingOrInvalid = { type: 'error', @@ -1149,7 +1149,7 @@ describe('challengeRoutes', () => { mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => Promise.resolve({ type: 'success', - msGameStatusApiUrl: solutionUrl + msUserAchievementsApiUrl: solutionUrl }) ); const msUsername = 'ANRandom'; @@ -1192,7 +1192,7 @@ describe('challengeRoutes', () => { mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => Promise.resolve({ type: 'success', - msGameStatusApiUrl: solutionUrl + msUserAchievementsApiUrl: solutionUrl }) ); const msUsername = 'ANRandom'; @@ -1205,7 +1205,7 @@ describe('challengeRoutes', () => { mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => Promise.resolve({ type: 'success', - msGameStatusApiUrl: solutionUrl + msUserAchievementsApiUrl: solutionUrl }) ); const resTwo = await superPost( @@ -1217,7 +1217,7 @@ describe('challengeRoutes', () => { mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => Promise.resolve({ type: 'success', - msGameStatusApiUrl: solutionUrl + msUserAchievementsApiUrl: solutionUrl }) ); const resUpdate = await superPost( diff --git a/api/src/routes/challenge.ts b/api/src/routes/challenge.ts index d28da3d314f..412884aa6bc 100644 --- a/api/src/routes/challenge.ts +++ b/api/src/routes/challenge.ts @@ -588,7 +588,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( const newChallenge = { id: challengeId, completedDate, - solution: msTrophyStatus.msGameStatusApiUrl + solution: msTrophyStatus.msUserAchievementsApiUrl }; await fastify.prisma.user.update({ where: { id: req.session.user.id }, diff --git a/api/src/routes/helpers/challenge-helpers.test.ts b/api/src/routes/helpers/challenge-helpers.test.ts index 825692eb762..c2cc3d56d4c 100644 --- a/api/src/routes/helpers/challenge-helpers.test.ts +++ b/api/src/routes/helpers/challenge-helpers.test.ts @@ -79,7 +79,7 @@ describe('Challenge Helpers', () => { 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}`; + const achievementsUrl = `https://learn.microsoft.com/api/achievements/user/${userId}`; afterEach(() => jest.clearAllMocks()); @@ -98,13 +98,13 @@ describe('Challenge Helpers', () => { }); }); - test("handles failure to reach Microsoft's gamestatus api", async () => { + test("handles failure to reach Microsoft's achievements api", async () => { const fetchProfile = createFetchMock({ body: { userId } }); - const fetchGameStatus = createFetchMock({ ok: false }); + const fetchAchievements = createFetchMock({ ok: false }); jest .spyOn(globalThis, 'fetch') .mockImplementationOnce(fetchProfile) - .mockImplementationOnce(fetchGameStatus); + .mockImplementationOnce(fetchAchievements); const verification = await verifyTrophyWithMicrosoft(verifyData); @@ -116,11 +116,11 @@ describe('Challenge Helpers', () => { test('handles the case where the user has no achievements', async () => { const fetchProfile = createFetchMock({ body: { userId } }); - const fetchGameStatus = createFetchMock({ body: { achievements: [] } }); + const fetchAchievements = createFetchMock({ body: { achievements: [] } }); jest .spyOn(globalThis, 'fetch') .mockImplementationOnce(fetchProfile) - .mockImplementationOnce(fetchGameStatus); + .mockImplementationOnce(fetchAchievements); const verification = await verifyTrophyWithMicrosoft(verifyData); @@ -132,13 +132,13 @@ describe('Challenge Helpers', () => { 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' }] } + const fetchAchievements = createFetchMock({ + body: { achievements: [{ typeId: 'fake-id' }] } }); jest .spyOn(globalThis, 'fetch') .mockImplementationOnce(fetchProfile) - .mockImplementationOnce(fetchGameStatus); + .mockImplementationOnce(fetchAchievements); const verification = await verifyTrophyWithMicrosoft(verifyData); @@ -151,21 +151,21 @@ describe('Challenge Helpers', () => { }); }); - test('returns msGameStatusApiUrl on success', async () => { + test('returns msUserAchievementsApiUrl on success', async () => { const fetchProfile = createFetchMock({ body: { userId } }); - const fetchGameStatus = createFetchMock({ - body: { achievements: [{ awardUid: msTrophyId }] } + const fetchAchievements = createFetchMock({ + body: { achievements: [{ typeId: msTrophyId }] } }); jest .spyOn(globalThis, 'fetch') .mockImplementationOnce(fetchProfile) - .mockImplementationOnce(fetchGameStatus); + .mockImplementationOnce(fetchAchievements); const verification = await verifyTrophyWithMicrosoft(verifyData); expect(verification).toEqual({ type: 'success', - msGameStatusApiUrl: gamestatusUrl + msUserAchievementsApiUrl: achievementsUrl }); }); }); diff --git a/api/src/routes/helpers/challenge-helpers.ts b/api/src/routes/helpers/challenge-helpers.ts index 8ec70e6b2e0..0b71f54b32a 100644 --- a/api/src/routes/helpers/challenge-helpers.ts +++ b/api/src/routes/helpers/challenge-helpers.ts @@ -59,27 +59,64 @@ export const createProject = ( progressTimestamps: [...progressTimestamps, newChallenge.completedDate] }); +type MSProfileError = { + type: 'error'; + message: 'flash.ms.profile.err'; + variables: { msUsername: string }; +}; + +type MSProfileSuccess = { + type: 'success'; + userId: string; +}; + async function getMSProfile(msUsername: string) { - const profileError = { + const error: MSProfileError = { 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; + if (!msProfileApiRes.ok) return error; const { userId } = (await msProfileApiRes.json()) as { userId: string; }; - return userId ? ({ type: 'success', userId } as const) : profileError; + const success: MSProfileSuccess = { + type: 'success', + userId + }; + + return userId ? success : error; } +type AchievementsError = { + type: 'error'; + message: 'flash.ms.trophy.err-3'; +}; + +type NoAchievementsError = { + type: 'error'; + message: 'flash.ms.trophy.err-6'; +}; + +type NoTrophyError = { + type: 'error'; + message: 'flash.ms.trophy.err-4'; + variables: { msUsername: string }; +}; + +type Validated = { + type: 'success'; + msUserAchievementsApiUrl: string; +}; + /** * Handles all communication with the Microsoft Learn APIs. * @@ -99,33 +136,37 @@ export async function verifyTrophyWithMicrosoft({ if (msProfile.type === 'error') return msProfile; - const msGameStatusApiUrl = `https://learn.microsoft.com/api/gamestatus/${msProfile.userId}`; - const msGameStatusApiRes = await fetch(msGameStatusApiUrl); + const msUserAchievementsApiUrl = `https://learn.microsoft.com/api/achievements/user/${msProfile.userId}`; + const msUserAchievementsApiRes = await fetch(msUserAchievementsApiUrl); - if (!msGameStatusApiRes.ok) { + if (!msUserAchievementsApiRes.ok) { return { type: 'error', message: 'flash.ms.trophy.err-3' - } as const; + } as AchievementsError; } - const { achievements } = (await msGameStatusApiRes.json()) as { - achievements?: { awardUid: string }[]; + const { achievements } = (await msUserAchievementsApiRes.json()) as { + achievements?: { typeId: string }[]; }; if (!achievements?.length) return { type: 'error', message: 'flash.ms.trophy.err-6' - } as const; + } as NoAchievementsError; - const earnedTrophy = achievements?.some(a => a.awardUid === msTrophyId); + // TODO: handle the case where there are achievements, but the `typeId` is not + // a property of the achievements. This suggests that Microsoft has changed + // their API and, to aid debugging, we should report a different error + // message. + const earnedTrophy = achievements?.some(a => a.typeId === msTrophyId); if (earnedTrophy) { return { type: 'success', - msGameStatusApiUrl - } as const; + msUserAchievementsApiUrl + } as Validated; } else { return { type: 'error', @@ -133,6 +174,6 @@ export async function verifyTrophyWithMicrosoft({ variables: { msUsername } - } as const; + } as NoTrophyError; } }