From ff958051e4e5d05aed4407f3386427f2f90ac2bd Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Tue, 28 Apr 2026 14:36:39 -0700 Subject: [PATCH 1/3] Updated React Dependency Versions This stops a 'Conflicting Peer Dependency' error from occurring when you run npm install while react is currently below 18.3.1 --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2deb6d09..18625c56d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,9 @@ "json-server": "0.17.1", "next": "^15.5.12", "next-auth": "4.24.5", - "react": "^18.2.0", + "react": "^18.3.1", "react-data-table-component": "7.5.3", - "react-dom": "^18.2.0", + "react-dom": "^18.3.1", "react-multi-select-component": "4.3.4", "react-table": "^7.8.0", "react-tabs": "4.3.0", diff --git a/package.json b/package.json index b475a58b8..a1637107d 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,9 @@ "json-server": "0.17.1", "next": "^15.5.12", "next-auth": "4.24.5", - "react": "^18.2.0", + "react": "^18.3.1", "react-data-table-component": "7.5.3", - "react-dom": "^18.2.0", + "react-dom": "^18.3.1", "react-multi-select-component": "4.3.4", "react-table": "^7.8.0", "react-tabs": "4.3.0", From a31f67abc04703afe80a0c107ad0e200b2c2a1f6 Mon Sep 17 00:00:00 2001 From: Newton Ly Chung Date: Fri, 26 Jun 2026 10:11:10 -0700 Subject: [PATCH 2/3] test: add consent revocation coverage for fetchClassroomStudentData --- __tests__/utils/fccApi.test.js | 316 +++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 __tests__/utils/fccApi.test.js diff --git a/__tests__/utils/fccApi.test.js b/__tests__/utils/fccApi.test.js new file mode 100644 index 000000000..4728cc328 --- /dev/null +++ b/__tests__/utils/fccApi.test.js @@ -0,0 +1,316 @@ +global.fetch = jest.fn(); + +// Must be declared before require() calls so jest hoisting can intercept +// the dynamic import inside fetchClassroomStudentData. +jest.mock('../../util/challengeMapUtils', () => ({ + resolveAllStudentsToDashboardFormat: jest.fn(emailKeyedData => + Object.entries(emailKeyedData).map(([email, challenges]) => ({ + email, + certifications: challenges + })) + ) +})); + +const { fetchUserData } = require('../../util/fcc-api'); +const { + fetchClassroomStudentData +} = require('../../util/student/fetchStudentData'); +const { + resolveAllStudentsToDashboardFormat +} = require('../../util/challengeMapUtils'); + +describe('FCC API handshake', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.FCC_API_URL = 'http://localhost:3000'; + process.env.TPA_API_BEARER_TOKEN = 'test-bearer-token'; + }); + + afterEach(() => { + delete process.env.FCC_API_URL; + delete process.env.TPA_API_BEARER_TOKEN; + }); + + // --------------------------------------------------------------------------- + // fetchUserData — /apps/classroom/get-user-data + // --------------------------------------------------------------------------- + describe('fetchUserData', () => { + it('returns { data: {} } immediately for an empty userIds array without hitting the network', async () => { + const result = await fetchUserData([]); + + expect(result).toEqual({ data: {} }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns { data: {} } for null userIds without hitting the network', async () => { + const result = await fetchUserData(null); + + expect(result).toEqual({ data: {} }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('POSTs to the correct get-user-data endpoint with userIds in the body', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: { uid1: [] } }) + }); + + await fetchUserData(['uid1', 'uid2']); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:3000/apps/classroom/get-user-data', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ userIds: ['uid1', 'uid2'] }) + }) + ); + }); + + it('includes the Bearer token in the Authorization header', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }) + }); + + await fetchUserData(['uid1']); + + const [, options] = global.fetch.mock.calls[0]; + expect(options.headers['Authorization']).toBe('Bearer test-bearer-token'); + }); + + it('returns challenge completion data keyed by fCC user ID', async () => { + const mockData = { + uid1: [{ id: 'bd7123c8c441eddfaeb5bdef', completedDate: 1700000000000 }] + }; + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: mockData }) + }); + + const result = await fetchUserData(['uid1']); + + expect(result.data).toEqual(mockData); + }); + + it('splits requests into batches of 50 when more than 50 user IDs are provided', async () => { + const userIds = Array.from({ length: 55 }, (_, i) => `uid${i}`); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }) + }); + + await fetchUserData(userIds); + + expect(global.fetch).toHaveBeenCalledTimes(2); + const firstBatchBody = JSON.parse(global.fetch.mock.calls[0][1].body); + const secondBatchBody = JSON.parse(global.fetch.mock.calls[1][1].body); + expect(firstBatchBody.userIds).toHaveLength(50); + expect(secondBatchBody.userIds).toHaveLength(5); + }); + + it('sends exactly one request when userIds length equals the batch size limit (50)', async () => { + const userIds = Array.from({ length: 50 }, (_, i) => `uid${i}`); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }) + }); + + await fetchUserData(userIds); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('merges batch results into a single data object', async () => { + const userIds = Array.from({ length: 55 }, (_, i) => `uid${i}`); + const batch1Data = Object.fromEntries( + userIds.slice(0, 50).map(id => [id, []]) + ); + const batch2Data = Object.fromEntries( + userIds.slice(50).map(id => [id, []]) + ); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: batch1Data }) + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: batch2Data }) + }); + + const result = await fetchUserData(userIds); + + expect(Object.keys(result.data)).toHaveLength(55); + expect(result.data).toMatchObject(batch1Data); + expect(result.data).toMatchObject(batch2Data); + }); + + it('throws when the FCC API returns a non-ok response', async () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + global.fetch.mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({ error: 'Forbidden' }) + }); + + await expect(fetchUserData(['uid1'])).rejects.toThrow( + 'Failed to fetch student data from fCC API' + ); + + expect(consoleSpy).toHaveBeenCalledWith( + 'get-user-data failed', + 403, + 'Forbidden' + ); + consoleSpy.mockRestore(); + }); + }); + + // --------------------------------------------------------------------------- + // fetchClassroomStudentData — orchestrates fetchUserData + dashboard mapping + // --------------------------------------------------------------------------- + describe('fetchClassroomStudentData', () => { + it('returns an empty array when no students have a linked fccProperUserId', async () => { + const students = [ + { id: 's1', email: 'a@test.com', fccProperUserId: null }, + { id: 's2', email: 'b@test.com', fccProperUserId: null } + ]; + + const result = await fetchClassroomStudentData(students); + + expect(result).toEqual([]); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('only requests data for students who have a linked fccProperUserId', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: { 'fcc-uid-1': [] } }) + }); + + const students = [ + { id: 's1', email: 'linked@test.com', fccProperUserId: 'fcc-uid-1' }, + { id: 's2', email: 'unlinked@test.com', fccProperUserId: null } + ]; + + await fetchClassroomStudentData(students); + + const body = JSON.parse(global.fetch.mock.calls[0][1].body); + expect(body.userIds).toEqual(['fcc-uid-1']); + }); + + it('translates fccProperUserId back to student email before passing data to the dashboard formatter', async () => { + const completedChallenges = [ + { id: 'bd7123c8c441eddfaeb5bdef', completedDate: 1700000000000 } + ]; + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { 'fcc-uid-1': completedChallenges } + }) + }); + + const students = [ + { + id: 's1', + email: 'student@test.com', + fccProperUserId: 'fcc-uid-1' + } + ]; + + await fetchClassroomStudentData(students); + + expect(resolveAllStudentsToDashboardFormat).toHaveBeenCalledWith( + expect.objectContaining({ + 'student@test.com': completedChallenges + }) + ); + }); + + it('does not include fCC user IDs as keys in the dashboard formatter input', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { 'fcc-uid-1': [] } + }) + }); + + const students = [ + { id: 's1', email: 'student@test.com', fccProperUserId: 'fcc-uid-1' } + ]; + + await fetchClassroomStudentData(students); + + const formatterArg = resolveAllStudentsToDashboardFormat.mock.calls[0][0]; + // Use Object.keys to avoid Jest's dot-notation interpretation of email addresses + expect(Object.keys(formatterArg)).not.toContain('fcc-uid-1'); + expect(Object.keys(formatterArg)).toContain('student@test.com'); + }); + + it('returns the dashboard-formatted result from resolveAllStudentsToDashboardFormat', async () => { + const challenges = [ + { id: 'bd7123c8c441eddfaeb5bdef', completedDate: 1700000000000 } + ]; + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: { 'fcc-uid-1': challenges } }) + }); + + const students = [ + { id: 's1', email: 'student@test.com', fccProperUserId: 'fcc-uid-1' } + ]; + + const result = await fetchClassroomStudentData(students); + + // Our mock resolveAllStudentsToDashboardFormat returns { email, certifications: challenges } + expect(result).toEqual([ + { email: 'student@test.com', certifications: challenges } + ]); + }); + + it('drops fCC user IDs that do not map to any classroom student email', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + // FCC returns an extra ID not in our students list + data: { 'fcc-uid-1': [], 'unexpected-uid': [] } + }) + }); + + const students = [ + { id: 's1', email: 'student@test.com', fccProperUserId: 'fcc-uid-1' } + ]; + + await fetchClassroomStudentData(students); + + const formatterArg = resolveAllStudentsToDashboardFormat.mock.calls[0][0]; + expect(Object.keys(formatterArg)).toEqual(['student@test.com']); + }); + + it('silently drops a student whose fCC user ID is absent from the API response (consent revoked)', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + // fcc-uid-1 was requested but is absent — user revoked consent + data: { 'fcc-uid-2': [] } + }) + }); + + const students = [ + { id: 's1', email: 'revoked@test.com', fccProperUserId: 'fcc-uid-1' }, + { id: 's2', email: 'active@test.com', fccProperUserId: 'fcc-uid-2' } + ]; + + const result = await fetchClassroomStudentData(students); + + const emails = result.map(s => s.email); + expect(emails).not.toContain('revoked@test.com'); + expect(emails).toContain('active@test.com'); + }); + }); +}); From e54d3170f04c6f82499d2d00d14470d6a1fd99f7 Mon Sep 17 00:00:00 2001 From: Newton Ly Chung Date: Fri, 26 Jun 2026 11:24:08 -0700 Subject: [PATCH 3/3] docs: add FCC Proper API integration local testing guide to README --- README.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ca1a61db4..1a92915ed 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ## Motivation -For a while now teachers have been asking for a way to get a birds eye view of multiple students who are progressing through the course. This is why we set out to make freeCodeCamp classroom mode, an interactive dashboard for teachers to view multiple freeCodeCamp users’ progress on their courses. +For a while now teachers have been asking for a way to get a birds eye view of multiple students who are progressing through the course. This is why we set out to make freeCodeCamp classroom mode, an interactive dashboard for teachers to view multiple freeCodeCamp users' progress on their courses. ## Contributing @@ -218,6 +218,82 @@ In production environments with separate domains (e.g., `classroom.freecodecamp. --- +### Testing the FCC Proper API Integration + +This section covers how to run FCC Classroom alongside FCC Proper locally to test the real student completion data integration. + +**Prerequisites** + +You must have completed the [freeCodeCamp contributor setup guide](https://contribute.freecodecamp.org/#/how-to-setup-freecodecamp-locally) and have a working local FCC Proper installation before following these instructions. You will also need Auth0 configured for both applications as described in the [dual Auth0 setup section](#auth0-configuration-for-local-fcc-proper-integration-with-local-fcc-classroom---dual-setup) above. + +#### FCC Proper Setup + +1. Clone/checkout the **main branch** of [freeCodeCamp](https://github.com/freeCodeCamp/freeCodeCamp). +2. In your FCC Proper `.env`, copy any new variables from `sample.env` and set the following: + - `FCC_ENABLE_CLASSROOM=true` + - `TPA_API_BEARER_TOKEN=local-test-secret-123` + - `FCC_ENABLE_DEV_LOGIN_MODE` — see the workflow sections below for which value to use + - Keep your existing Auth0 variables unchanged +3. In `client/config/growthbook-features-default.json`, find the `classroom-mode` feature and set its `defaultValue` to `true`. +4. Ensure you are running Node 24.x and pnpm 10.x. +5. If you have previously set up FCC Proper, **clear your database** before continuing to avoid stale data conflicts. +6. Run `pnpm install`. +7. Run `docker start mongodb` and `pnpm run develop`. + +#### Classroom Setup + +In your Classroom `.env`, set: + +``` +FCC_API_URL=http://localhost:3000 +TPA_API_BEARER_TOKEN=local-test-secret-123 +``` + +Then run: + +```console +npx prisma migrate reset +``` + +Open two separate terminals and run: + +```console +npm run develop +``` + +```console +npx prisma studio +``` + +#### Workflow A — Full Onboarding Flow (tests the `get-user-id` endpoint) + +Use this to test the complete student join and connect flow end-to-end. + +Set `FCC_ENABLE_DEV_LOGIN_MODE=false` in FCC Proper. + +1. Sign into FCC Proper with your **student** account. Go to Settings → Classroom Mode and enable data sharing. +2. Sign into FCC Classroom with your **teacher** account. In Prisma Studio, set your account's role to `TEACHER`, then create a classroom on the site. +3. Copy the classroom join link and open it in a new tab. Log into Classroom with your **student** account and click **Connect to Classroom**. + +#### Workflow B — Shortcut for Testing `get-user-data` (dashboard/classes page) + +Use this to verify that student completion data appears correctly on the dashboard, without going through the full onboarding flow. + +Set `FCC_ENABLE_DEV_LOGIN_MODE=true` in FCC Proper, then run: + +```console +pnpm run seed:certified-user +``` + +1. Sign into FCC Proper and go to Settings → Classroom Mode → enable data sharing. +2. Sign into FCC Classroom with your **teacher** account. In Prisma Studio, set your account's role to `TEACHER`. +3. In Prisma Studio, find the student `foo@bar.com` and set their `fccProperUserId` to `[TODO]`. +4. Create a classroom on FCC Classroom. +5. In Prisma Studio, copy the `id` of `foo@bar.com` and add it to the `fccUserIds` list on your classroom record. +6. Navigate to your classroom — the student should now appear in the dashboard with their completion data. + +--- + ### Join us in our [Discord Chat](https://discord.gg/qcynkd4Edx) here. ---