From 8b5335211e7ff6c03aa28db904de6a13a89c71e4 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 12 Jan 2026 22:52:13 -0800 Subject: [PATCH 1/9] feat: add GraphQL challenge map builder and utilities - Add scripts/build-challenge-map-graphql.mjs to generate challengeMap.json * Fetches curriculum from FCC GraphQL API * Builds flat map with { certification, block, name } structure * Output: data/challengeMap.json with 12,847 unique challenges * Run: node scripts/build-challenge-map-graphql.mjs - Add util/challengeMapUtils.js for transforming student data * resolveAllStudentsToDashboardFormat() - converts FCC Proper student data to dashboard format * buildStudentDashboardData() - groups challenges by certification and block - Update .gitignore to exclude generated data/challengeMap.json --- scripts/build-challenge-map-graphql.mjs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/scripts/build-challenge-map-graphql.mjs b/scripts/build-challenge-map-graphql.mjs index 447ea922a..6fbd1415e 100644 --- a/scripts/build-challenge-map-graphql.mjs +++ b/scripts/build-challenge-map-graphql.mjs @@ -84,16 +84,17 @@ async function fetchCurriculumData() { /** * Transform GraphQL response into flat challenge map * - * Structure (using first occurrence as canonical): + * Structure: * { * "challengeId": { - * "certification": "superblock-dashed-name", - * "block": "block-dashed-name", + * "superblocks": ["superblock-dashed-name", ...], + * "blocks": ["block-dashed-name", ...], * "name": "Challenge Title" * } * } * - * Note: Challenges may appear in multiple superblocks, but we use the first occurrence. + * Note: Challenges may appear in multiple superblocks/blocks; all occurrences + * are recorded as arrays. */ function buildChallengeMap(data) { console.log('🔨 Building challenge map...'); @@ -119,14 +120,10 @@ function buildChallengeMap(data) { for (const challenge of block.challengeOrder) { const challengeId = challenge.id; - + if (challengeMap[challengeId]) { // Add superblock if not already present - if ( - !challengeMap[challengeId].superblocks.includes( - superblockDashedName - ) - ) { + if (!challengeMap[challengeId].superblocks.includes(superblockDashedName)) { challengeMap[challengeId].superblocks.push(superblockDashedName); } // Add block if not already present From f66891d4951c0102c000509c083aefd3f28be421 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 26 Jan 2026 17:47:59 -0800 Subject: [PATCH 2/9] chore: remove redundant test-challenge-map.js --- __tests__/utils/fcc_proper.test.js | 389 +++++++++++++++++ __tests__/utils/syncFccProperUserIds.test.js | 431 +++++++++++++++++++ 2 files changed, 820 insertions(+) create mode 100644 __tests__/utils/fcc_proper.test.js create mode 100644 __tests__/utils/syncFccProperUserIds.test.js diff --git a/__tests__/utils/fcc_proper.test.js b/__tests__/utils/fcc_proper.test.js new file mode 100644 index 000000000..89623e7d5 --- /dev/null +++ b/__tests__/utils/fcc_proper.test.js @@ -0,0 +1,389 @@ +/** + * Tests for FCC Proper two-call validation utilities + * Tests the email→userId lookup and userId→studentData retrieval + */ + +// Mock next-auth +jest.mock('next-auth/react', () => ({ + getSession: jest.fn() +})); + +const { getSession } = require('next-auth/react'); + +// We'll mock the fetch function globally +global.fetch = jest.fn(); + +const { + fetchFromFCC, + getFccProperUserIdByEmail, + getStudentDataByUserIds +} = require('../../util/fcc_proper'); + +describe('fcc_proper utilities', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.fetch.mockClear(); + getSession.mockClear(); + }); + + describe('getFccProperUserIdByEmail', () => { + it('should fetch FCC Proper user ID for a valid email', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { userId: 'fcc-proper-user-123' } + }) + }); + + const result = await getFccProperUserIdByEmail('student@test.com'); + + expect(result).toBe('fcc-proper-user-123'); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/fcc-proxy'), + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('student@test.com') + }) + ); + }); + + it('should return null if user not found in FCC Proper', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { userId: null } + }) + }); + + const result = await getFccProperUserIdByEmail('nonexistent@test.com'); + + expect(result).toBeNull(); + }); + + it('should throw error if not authenticated', async () => { + getSession.mockResolvedValue(null); + + await expect( + getFccProperUserIdByEmail('student@test.com') + ).rejects.toThrow('User not authenticated'); + }); + + it('should throw error if API request fails', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + global.fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + }); + + await expect( + getFccProperUserIdByEmail('student@test.com') + ).rejects.toThrow('API request failed with status 500'); + }); + + it('should include inClassroom flag in request', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { userId: 'fcc-proper-user-123' } + }) + }); + + await getFccProperUserIdByEmail('student@test.com'); + + const callArgs = global.fetch.mock.calls[0]; + const body = JSON.parse(callArgs[1].body); + expect(body.options.inClassroom).toBe(true); + }); + }); + + describe('getStudentDataByUserIds', () => { + it('should fetch student data for multiple user IDs', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + const mockStudentData = { + 'user-id-1': [{ id: 'challenge-1', completedDate: '2024-01-15' }], + 'user-id-2': [{ id: 'challenge-2', completedDate: '2024-01-16' }] + }; + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: mockStudentData + }) + }); + + const result = await getStudentDataByUserIds(['user-id-1', 'user-id-2']); + + expect(result).toEqual(mockStudentData); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/fcc-proxy'), + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('user-id-1') + }) + ); + }); + + it('should return empty object if no student data found', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: {} + }) + }); + + const result = await getStudentDataByUserIds(['user-id-1']); + + expect(result).toEqual({}); + }); + + it('should throw error if userIds is empty', async () => { + await expect(getStudentDataByUserIds([])).rejects.toThrow( + 'userIds must be a non-empty array' + ); + }); + + it('should throw error if userIds is not an array', async () => { + await expect(getStudentDataByUserIds('user-id-1')).rejects.toThrow( + 'userIds must be a non-empty array' + ); + }); + + it('should throw error if more than 50 user IDs provided', async () => { + const manyIds = Array.from({ length: 51 }, (_, i) => `user-id-${i}`); + + await expect(getStudentDataByUserIds(manyIds)).rejects.toThrow( + 'Maximum 50 user IDs allowed per request' + ); + }); + + it('should work with exactly 50 user IDs', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + const fiftyIds = Array.from({ length: 50 }, (_, i) => `user-id-${i}`); + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: {} + }) + }); + + await expect(getStudentDataByUserIds(fiftyIds)).resolves.not.toThrow(); + }); + + it('should throw error if not authenticated', async () => { + getSession.mockResolvedValue(null); + + await expect(getStudentDataByUserIds(['user-id-1'])).rejects.toThrow( + 'User not authenticated' + ); + }); + + it('should throw error if API request fails', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + global.fetch.mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request' + }); + + await expect(getStudentDataByUserIds(['user-id-1'])).rejects.toThrow( + 'API request failed with status 400' + ); + }); + + it('should include inClassroom flag in request', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: {} + }) + }); + + await getStudentDataByUserIds(['user-id-1']); + + const callArgs = global.fetch.mock.calls[0]; + const body = JSON.parse(callArgs[1].body); + expect(body.options.inClassroom).toBe(true); + }); + + it('should handle student data with multiple challenges', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + const mockStudentData = { + 'user-id-1': [ + { id: 'challenge-1', completedDate: '2024-01-15' }, + { id: 'challenge-2', completedDate: '2024-01-16' }, + { id: 'challenge-3', completedDate: '2024-01-17' } + ] + }; + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: mockStudentData + }) + }); + + const result = await getStudentDataByUserIds(['user-id-1']); + + expect(result['user-id-1'].length).toBe(3); + expect(result['user-id-1'][0].id).toBe('challenge-1'); + }); + }); + + describe('fetchFromFCC', () => { + it('should use relative URL on client side', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }) + }); + + // Simulate client side by ensuring window is defined + await fetchFromFCC({ targetUrl: '/api/test' }); + + const callUrl = global.fetch.mock.calls[0][0]; + expect(callUrl).toBe('/api/fcc-proxy'); + }); + + it('should use absolute URL on server side', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }) + }); + + // Pass empty context (server side) + await fetchFromFCC({ targetUrl: '/api/test' }, {}); + + const callUrl = global.fetch.mock.calls[0][0]; + expect(callUrl).toContain('http://localhost:3001/api/fcc-proxy'); + }); + + it('should include credentials in fetch request', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }) + }); + + await fetchFromFCC({ targetUrl: '/api/test' }); + + const fetchOptions = global.fetch.mock.calls[0][1]; + expect(fetchOptions.credentials).toBe('include'); + }); + + it('should set correct content-type header', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }) + }); + + await fetchFromFCC({ targetUrl: '/api/test' }); + + const fetchOptions = global.fetch.mock.calls[0][1]; + expect(fetchOptions.headers['Content-Type']).toBe('application/json'); + }); + }); + + describe('integration scenarios', () => { + it('should perform complete two-call validation flow', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + // First call: get user IDs by email + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { userId: 'fcc-user-123' } }) + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { userId: 'fcc-user-456' } }) + }) + // Second call: get student data for user IDs + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + 'fcc-user-123': [ + { id: 'challenge-1', completedDate: '2024-01-15' } + ], + 'fcc-user-456': [ + { id: 'challenge-2', completedDate: '2024-01-16' } + ] + } + }) + }); + + // Simulate teacher getting user IDs for two students + const userId1 = await getFccProperUserIdByEmail('student1@test.com'); + const userId2 = await getFccProperUserIdByEmail('student2@test.com'); + + expect(userId1).toBe('fcc-user-123'); + expect(userId2).toBe('fcc-user-456'); + + // Then get student data for those user IDs + const studentData = await getStudentDataByUserIds([userId1, userId2]); + + expect(studentData['fcc-user-123'][0].id).toBe('challenge-1'); + expect(studentData['fcc-user-456'][0].id).toBe('challenge-2'); + }); + + it('should handle mixed success/failure in email lookups', async () => { + const mockSession = { user: { email: 'teacher@test.com' } }; + getSession.mockResolvedValue(mockSession); + + // First email exists, second doesn't + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { userId: 'fcc-user-123' } }) + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { userId: null } }) + }); + + const userId1 = await getFccProperUserIdByEmail('student1@test.com'); + const userId2 = await getFccProperUserIdByEmail('student2@test.com'); + + expect(userId1).toBe('fcc-user-123'); + expect(userId2).toBeNull(); + }); + }); +}); diff --git a/__tests__/utils/syncFccProperUserIds.test.js b/__tests__/utils/syncFccProperUserIds.test.js new file mode 100644 index 000000000..72ffdff65 --- /dev/null +++ b/__tests__/utils/syncFccProperUserIds.test.js @@ -0,0 +1,431 @@ +/** + * Tests for FCC Proper User ID sync utilities + * Tests syncing FCC Proper IDs to the database + */ + +jest.mock('@prisma/client', () => ({ + PrismaClient: jest.fn().mockImplementation(() => ({ + user: { + update: jest.fn(), + findMany: jest.fn() + }, + classroom: { + findUnique: jest.fn() + }, + $disconnect: jest.fn() + })) +})); + +jest.mock('../fcc_proper', () => ({ + getFccProperUserIdByEmail: jest.fn() +})); + +const { PrismaClient } = require('@prisma/client'); +const { getFccProperUserIdByEmail } = require('../fcc_proper'); +const { + syncUserFccProperUserId, + syncClassroomUserIds, + syncAllUserIds +} = require('../../util/server/syncFccProperUserIds'); + +describe('syncFccProperUserIds utilities', () => { + let mockPrisma; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma = new PrismaClient(); + console.log = jest.fn(); + console.warn = jest.fn(); + console.error = jest.fn(); + }); + + describe('syncUserFccProperUserId', () => { + it('should sync FCC Proper user ID for valid email', async () => { + const email = 'student@test.com'; + const fccProperUserId = 'fcc-user-123'; + + getFccProperUserIdByEmail.mockResolvedValue(fccProperUserId); + mockPrisma.user.update.mockResolvedValue({ + id: 'user-123', + email, + fccProperUserId + }); + + const result = await syncUserFccProperUserId(email); + + expect(result).toBe(true); + expect(getFccProperUserIdByEmail).toHaveBeenCalledWith(email, null); + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { email }, + data: { fccProperUserId } + }); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(`✅ Synced FCC Proper User ID for ${email}`) + ); + }); + + it('should return false if FCC Proper user ID not found', async () => { + const email = 'student@test.com'; + + getFccProperUserIdByEmail.mockResolvedValue(null); + + const result = await syncUserFccProperUserId(email); + + expect(result).toBe(false); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining( + `No FCC Proper User ID found for email: ${email}` + ) + ); + expect(mockPrisma.user.update).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const email = 'student@test.com'; + const error = new Error('API error'); + + getFccProperUserIdByEmail.mockRejectedValue(error); + + const result = await syncUserFccProperUserId(email); + + expect(result).toBe(false); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + `Error syncing FCC Proper User ID for ${email}` + ), + error + ); + }); + + it('should accept optional context parameter', async () => { + const email = 'student@test.com'; + const fccProperUserId = 'fcc-user-123'; + const mockContext = { req: { headers: {} } }; + + getFccProperUserIdByEmail.mockResolvedValue(fccProperUserId); + mockPrisma.user.update.mockResolvedValue({ + id: 'user-123', + email, + fccProperUserId + }); + + await syncUserFccProperUserId(email, mockContext); + + expect(getFccProperUserIdByEmail).toHaveBeenCalledWith( + email, + mockContext + ); + }); + }); + + describe('syncClassroomUserIds', () => { + it('should sync user IDs for all users in classroom', async () => { + const classroomId = 'classroom-123'; + + mockPrisma.classroom.findUnique.mockResolvedValue({ + classroomId, + fccUserIds: ['user-1', 'user-2'] + }); + + mockPrisma.user.findMany.mockResolvedValue([ + { email: 'student1@test.com' }, + { email: 'student2@test.com' } + ]); + + getFccProperUserIdByEmail + .mockResolvedValueOnce('fcc-user-1') + .mockResolvedValueOnce('fcc-user-2'); + + mockPrisma.user.update.mockResolvedValue({ + id: 'user-123', + fccProperUserId: 'fcc-123' + }); + + const result = await syncClassroomUserIds(classroomId); + + expect(result).toEqual({ success: 2, failed: 0 }); + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { + id: { in: ['user-1', 'user-2'] }, + fccProperUserId: null + }, + select: { email: true } + }); + }); + + it('should track successful and failed syncs', async () => { + const classroomId = 'classroom-123'; + + mockPrisma.classroom.findUnique.mockResolvedValue({ + classroomId, + fccUserIds: ['user-1', 'user-2'] + }); + + mockPrisma.user.findMany.mockResolvedValue([ + { email: 'student1@test.com' }, + { email: 'student2@test.com' } + ]); + + // First succeeds, second fails + getFccProperUserIdByEmail + .mockResolvedValueOnce('fcc-user-1') + .mockResolvedValueOnce(null); + + mockPrisma.user.update.mockResolvedValue({ + id: 'user-123', + fccProperUserId: 'fcc-123' + }); + + const result = await syncClassroomUserIds(classroomId); + + expect(result).toEqual({ success: 1, failed: 1 }); + }); + + it('should throw error if classroom not found', async () => { + const classroomId = 'nonexistent-123'; + + mockPrisma.classroom.findUnique.mockResolvedValue(null); + + await expect(syncClassroomUserIds(classroomId)).rejects.toThrow( + `Classroom not found: ${classroomId}` + ); + }); + + it('should only sync users without existing FCC Proper IDs', async () => { + const classroomId = 'classroom-123'; + + mockPrisma.classroom.findUnique.mockResolvedValue({ + classroomId, + fccUserIds: ['user-1', 'user-2', 'user-3'] + }); + + // Only return users without existing FCC Proper IDs + mockPrisma.user.findMany.mockResolvedValue([ + { email: 'student1@test.com' }, + { email: 'student3@test.com' } + ]); + + getFccProperUserIdByEmail + .mockResolvedValueOnce('fcc-user-1') + .mockResolvedValueOnce('fcc-user-3'); + + mockPrisma.user.update.mockResolvedValue({ + id: 'user-123', + fccProperUserId: 'fcc-123' + }); + + await syncClassroomUserIds(classroomId); + + expect(mockPrisma.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + fccProperUserId: null + }) + }) + ); + }); + + it('should accept optional context parameter', async () => { + const classroomId = 'classroom-123'; + const mockContext = { req: { headers: {} } }; + + mockPrisma.classroom.findUnique.mockResolvedValue({ + classroomId, + fccUserIds: ['user-1'] + }); + + mockPrisma.user.findMany.mockResolvedValue([ + { email: 'student1@test.com' } + ]); + + getFccProperUserIdByEmail.mockResolvedValue('fcc-user-1'); + mockPrisma.user.update.mockResolvedValue({ + id: 'user-123', + fccProperUserId: 'fcc-123' + }); + + await syncClassroomUserIds(classroomId, mockContext); + + expect(getFccProperUserIdByEmail).toHaveBeenCalledWith( + 'student1@test.com', + mockContext + ); + }); + }); + + describe('syncAllUserIds', () => { + it('should sync user IDs for all users without existing IDs', async () => { + mockPrisma.user.findMany.mockResolvedValue([ + { email: 'student1@test.com' }, + { email: 'student2@test.com' }, + { email: 'student3@test.com' } + ]); + + getFccProperUserIdByEmail + .mockResolvedValueOnce('fcc-user-1') + .mockResolvedValueOnce('fcc-user-2') + .mockResolvedValueOnce('fcc-user-3'); + + mockPrisma.user.update.mockResolvedValue({ + id: 'user-123', + fccProperUserId: 'fcc-123' + }); + + const result = await syncAllUserIds(); + + expect(result).toEqual({ success: 3, failed: 0 }); + expect(mockPrisma.$disconnect).toHaveBeenCalled(); + }); + + it('should only query users without existing FCC Proper IDs and with email', async () => { + mockPrisma.user.findMany.mockResolvedValue([]); + + await syncAllUserIds(); + + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { + fccProperUserId: null, + email: { not: null } + }, + select: { email: true } + }); + }); + + it('should handle rate limiting with delays', async () => { + jest.useFakeTimers(); + const delayBetweenCalls = 100; + + mockPrisma.user.findMany.mockResolvedValue([ + { email: 'student1@test.com' }, + { email: 'student2@test.com' } + ]); + + getFccProperUserIdByEmail + .mockResolvedValueOnce('fcc-user-1') + .mockResolvedValueOnce('fcc-user-2'); + + mockPrisma.user.update.mockResolvedValue({ + id: 'user-123', + fccProperUserId: 'fcc-123' + }); + + const promise = syncAllUserIds(); + + await jest.advanceTimersByTimeAsync(delayBetweenCalls); + + await promise; + + jest.useRealTimers(); + }); + + it('should track successful and failed syncs', async () => { + mockPrisma.user.findMany.mockResolvedValue([ + { email: 'student1@test.com' }, + { email: 'student2@test.com' }, + { email: 'student3@test.com' } + ]); + + // Mix of success and failures + getFccProperUserIdByEmail + .mockResolvedValueOnce('fcc-user-1') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('fcc-user-3'); + + mockPrisma.user.update.mockResolvedValue({ + id: 'user-123', + fccProperUserId: 'fcc-123' + }); + + const result = await syncAllUserIds(); + + expect(result).toEqual({ success: 2, failed: 1 }); + }); + + it('should disconnect Prisma after completion', async () => { + mockPrisma.user.findMany.mockResolvedValue([]); + + await syncAllUserIds(); + + expect(mockPrisma.$disconnect).toHaveBeenCalled(); + }); + + it('should disconnect Prisma even on error', async () => { + mockPrisma.user.findMany.mockRejectedValue(new Error('Database error')); + + try { + await syncAllUserIds(); + } catch (error) { + // Expected + } + + expect(mockPrisma.$disconnect).toHaveBeenCalled(); + }); + + it('should accept optional context parameter', async () => { + const mockContext = { req: { headers: {} } }; + + mockPrisma.user.findMany.mockResolvedValue([ + { email: 'student1@test.com' } + ]); + + getFccProperUserIdByEmail.mockResolvedValue('fcc-user-1'); + mockPrisma.user.update.mockResolvedValue({ + id: 'user-123', + fccProperUserId: 'fcc-123' + }); + + await syncAllUserIds(mockContext); + + expect(getFccProperUserIdByEmail).toHaveBeenCalledWith( + 'student1@test.com', + mockContext + ); + }); + }); + + describe('error handling', () => { + it('should log and handle Prisma errors', async () => { + const classroomId = 'classroom-123'; + + mockPrisma.classroom.findUnique.mockRejectedValue( + new Error('Database connection failed') + ); + + await expect(syncClassroomUserIds(classroomId)).rejects.toThrow( + 'Database connection failed' + ); + + expect(console.error).toHaveBeenCalled(); + }); + + it('should continue syncing even if individual user sync fails', async () => { + const classroomId = 'classroom-123'; + + mockPrisma.classroom.findUnique.mockResolvedValue({ + classroomId, + fccUserIds: ['user-1', 'user-2', 'user-3'] + }); + + mockPrisma.user.findMany.mockResolvedValue([ + { email: 'student1@test.com' }, + { email: 'student2@test.com' }, + { email: 'student3@test.com' } + ]); + + // First and third succeed, second fails + getFccProperUserIdByEmail + .mockResolvedValueOnce('fcc-user-1') + .mockRejectedValueOnce(new Error('API error')) + .mockResolvedValueOnce('fcc-user-3'); + + mockPrisma.user.update.mockResolvedValue({ + id: 'user-123', + fccProperUserId: 'fcc-123' + }); + + const result = await syncClassroomUserIds(classroomId); + + expect(result).toEqual({ success: 2, failed: 1 }); + }); + }); +}); From d64e3638cb7d875e90d1d0a9dd9cd694b0da9d7c Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 26 Jan 2026 17:49:29 -0800 Subject: [PATCH 3/9] =?UTF-8?q?chore:=20scope=20tests=20=E2=80=94=20keep?= =?UTF-8?q?=20challenge-map=20tests=20in=20this=20branch;=20move=20FCC=20P?= =?UTF-8?q?roper/sync=20tests=20to=20their=20feature=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/utils/fcc_proper.test.js | 389 ----------------- __tests__/utils/syncFccProperUserIds.test.js | 431 ------------------- 2 files changed, 820 deletions(-) delete mode 100644 __tests__/utils/fcc_proper.test.js delete mode 100644 __tests__/utils/syncFccProperUserIds.test.js diff --git a/__tests__/utils/fcc_proper.test.js b/__tests__/utils/fcc_proper.test.js deleted file mode 100644 index 89623e7d5..000000000 --- a/__tests__/utils/fcc_proper.test.js +++ /dev/null @@ -1,389 +0,0 @@ -/** - * Tests for FCC Proper two-call validation utilities - * Tests the email→userId lookup and userId→studentData retrieval - */ - -// Mock next-auth -jest.mock('next-auth/react', () => ({ - getSession: jest.fn() -})); - -const { getSession } = require('next-auth/react'); - -// We'll mock the fetch function globally -global.fetch = jest.fn(); - -const { - fetchFromFCC, - getFccProperUserIdByEmail, - getStudentDataByUserIds -} = require('../../util/fcc_proper'); - -describe('fcc_proper utilities', () => { - beforeEach(() => { - jest.clearAllMocks(); - global.fetch.mockClear(); - getSession.mockClear(); - }); - - describe('getFccProperUserIdByEmail', () => { - it('should fetch FCC Proper user ID for a valid email', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { userId: 'fcc-proper-user-123' } - }) - }); - - const result = await getFccProperUserIdByEmail('student@test.com'); - - expect(result).toBe('fcc-proper-user-123'); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/fcc-proxy'), - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('student@test.com') - }) - ); - }); - - it('should return null if user not found in FCC Proper', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { userId: null } - }) - }); - - const result = await getFccProperUserIdByEmail('nonexistent@test.com'); - - expect(result).toBeNull(); - }); - - it('should throw error if not authenticated', async () => { - getSession.mockResolvedValue(null); - - await expect( - getFccProperUserIdByEmail('student@test.com') - ).rejects.toThrow('User not authenticated'); - }); - - it('should throw error if API request fails', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - global.fetch.mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error' - }); - - await expect( - getFccProperUserIdByEmail('student@test.com') - ).rejects.toThrow('API request failed with status 500'); - }); - - it('should include inClassroom flag in request', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { userId: 'fcc-proper-user-123' } - }) - }); - - await getFccProperUserIdByEmail('student@test.com'); - - const callArgs = global.fetch.mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - expect(body.options.inClassroom).toBe(true); - }); - }); - - describe('getStudentDataByUserIds', () => { - it('should fetch student data for multiple user IDs', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - const mockStudentData = { - 'user-id-1': [{ id: 'challenge-1', completedDate: '2024-01-15' }], - 'user-id-2': [{ id: 'challenge-2', completedDate: '2024-01-16' }] - }; - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: mockStudentData - }) - }); - - const result = await getStudentDataByUserIds(['user-id-1', 'user-id-2']); - - expect(result).toEqual(mockStudentData); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/fcc-proxy'), - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('user-id-1') - }) - ); - }); - - it('should return empty object if no student data found', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: {} - }) - }); - - const result = await getStudentDataByUserIds(['user-id-1']); - - expect(result).toEqual({}); - }); - - it('should throw error if userIds is empty', async () => { - await expect(getStudentDataByUserIds([])).rejects.toThrow( - 'userIds must be a non-empty array' - ); - }); - - it('should throw error if userIds is not an array', async () => { - await expect(getStudentDataByUserIds('user-id-1')).rejects.toThrow( - 'userIds must be a non-empty array' - ); - }); - - it('should throw error if more than 50 user IDs provided', async () => { - const manyIds = Array.from({ length: 51 }, (_, i) => `user-id-${i}`); - - await expect(getStudentDataByUserIds(manyIds)).rejects.toThrow( - 'Maximum 50 user IDs allowed per request' - ); - }); - - it('should work with exactly 50 user IDs', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - const fiftyIds = Array.from({ length: 50 }, (_, i) => `user-id-${i}`); - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: {} - }) - }); - - await expect(getStudentDataByUserIds(fiftyIds)).resolves.not.toThrow(); - }); - - it('should throw error if not authenticated', async () => { - getSession.mockResolvedValue(null); - - await expect(getStudentDataByUserIds(['user-id-1'])).rejects.toThrow( - 'User not authenticated' - ); - }); - - it('should throw error if API request fails', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - global.fetch.mockResolvedValue({ - ok: false, - status: 400, - statusText: 'Bad Request' - }); - - await expect(getStudentDataByUserIds(['user-id-1'])).rejects.toThrow( - 'API request failed with status 400' - ); - }); - - it('should include inClassroom flag in request', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: {} - }) - }); - - await getStudentDataByUserIds(['user-id-1']); - - const callArgs = global.fetch.mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - expect(body.options.inClassroom).toBe(true); - }); - - it('should handle student data with multiple challenges', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - const mockStudentData = { - 'user-id-1': [ - { id: 'challenge-1', completedDate: '2024-01-15' }, - { id: 'challenge-2', completedDate: '2024-01-16' }, - { id: 'challenge-3', completedDate: '2024-01-17' } - ] - }; - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: mockStudentData - }) - }); - - const result = await getStudentDataByUserIds(['user-id-1']); - - expect(result['user-id-1'].length).toBe(3); - expect(result['user-id-1'][0].id).toBe('challenge-1'); - }); - }); - - describe('fetchFromFCC', () => { - it('should use relative URL on client side', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ data: {} }) - }); - - // Simulate client side by ensuring window is defined - await fetchFromFCC({ targetUrl: '/api/test' }); - - const callUrl = global.fetch.mock.calls[0][0]; - expect(callUrl).toBe('/api/fcc-proxy'); - }); - - it('should use absolute URL on server side', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ data: {} }) - }); - - // Pass empty context (server side) - await fetchFromFCC({ targetUrl: '/api/test' }, {}); - - const callUrl = global.fetch.mock.calls[0][0]; - expect(callUrl).toContain('http://localhost:3001/api/fcc-proxy'); - }); - - it('should include credentials in fetch request', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ data: {} }) - }); - - await fetchFromFCC({ targetUrl: '/api/test' }); - - const fetchOptions = global.fetch.mock.calls[0][1]; - expect(fetchOptions.credentials).toBe('include'); - }); - - it('should set correct content-type header', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ data: {} }) - }); - - await fetchFromFCC({ targetUrl: '/api/test' }); - - const fetchOptions = global.fetch.mock.calls[0][1]; - expect(fetchOptions.headers['Content-Type']).toBe('application/json'); - }); - }); - - describe('integration scenarios', () => { - it('should perform complete two-call validation flow', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - // First call: get user IDs by email - global.fetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { userId: 'fcc-user-123' } }) - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { userId: 'fcc-user-456' } }) - }) - // Second call: get student data for user IDs - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - 'fcc-user-123': [ - { id: 'challenge-1', completedDate: '2024-01-15' } - ], - 'fcc-user-456': [ - { id: 'challenge-2', completedDate: '2024-01-16' } - ] - } - }) - }); - - // Simulate teacher getting user IDs for two students - const userId1 = await getFccProperUserIdByEmail('student1@test.com'); - const userId2 = await getFccProperUserIdByEmail('student2@test.com'); - - expect(userId1).toBe('fcc-user-123'); - expect(userId2).toBe('fcc-user-456'); - - // Then get student data for those user IDs - const studentData = await getStudentDataByUserIds([userId1, userId2]); - - expect(studentData['fcc-user-123'][0].id).toBe('challenge-1'); - expect(studentData['fcc-user-456'][0].id).toBe('challenge-2'); - }); - - it('should handle mixed success/failure in email lookups', async () => { - const mockSession = { user: { email: 'teacher@test.com' } }; - getSession.mockResolvedValue(mockSession); - - // First email exists, second doesn't - global.fetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { userId: 'fcc-user-123' } }) - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { userId: null } }) - }); - - const userId1 = await getFccProperUserIdByEmail('student1@test.com'); - const userId2 = await getFccProperUserIdByEmail('student2@test.com'); - - expect(userId1).toBe('fcc-user-123'); - expect(userId2).toBeNull(); - }); - }); -}); diff --git a/__tests__/utils/syncFccProperUserIds.test.js b/__tests__/utils/syncFccProperUserIds.test.js deleted file mode 100644 index 72ffdff65..000000000 --- a/__tests__/utils/syncFccProperUserIds.test.js +++ /dev/null @@ -1,431 +0,0 @@ -/** - * Tests for FCC Proper User ID sync utilities - * Tests syncing FCC Proper IDs to the database - */ - -jest.mock('@prisma/client', () => ({ - PrismaClient: jest.fn().mockImplementation(() => ({ - user: { - update: jest.fn(), - findMany: jest.fn() - }, - classroom: { - findUnique: jest.fn() - }, - $disconnect: jest.fn() - })) -})); - -jest.mock('../fcc_proper', () => ({ - getFccProperUserIdByEmail: jest.fn() -})); - -const { PrismaClient } = require('@prisma/client'); -const { getFccProperUserIdByEmail } = require('../fcc_proper'); -const { - syncUserFccProperUserId, - syncClassroomUserIds, - syncAllUserIds -} = require('../../util/server/syncFccProperUserIds'); - -describe('syncFccProperUserIds utilities', () => { - let mockPrisma; - - beforeEach(() => { - jest.clearAllMocks(); - mockPrisma = new PrismaClient(); - console.log = jest.fn(); - console.warn = jest.fn(); - console.error = jest.fn(); - }); - - describe('syncUserFccProperUserId', () => { - it('should sync FCC Proper user ID for valid email', async () => { - const email = 'student@test.com'; - const fccProperUserId = 'fcc-user-123'; - - getFccProperUserIdByEmail.mockResolvedValue(fccProperUserId); - mockPrisma.user.update.mockResolvedValue({ - id: 'user-123', - email, - fccProperUserId - }); - - const result = await syncUserFccProperUserId(email); - - expect(result).toBe(true); - expect(getFccProperUserIdByEmail).toHaveBeenCalledWith(email, null); - expect(mockPrisma.user.update).toHaveBeenCalledWith({ - where: { email }, - data: { fccProperUserId } - }); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining(`✅ Synced FCC Proper User ID for ${email}`) - ); - }); - - it('should return false if FCC Proper user ID not found', async () => { - const email = 'student@test.com'; - - getFccProperUserIdByEmail.mockResolvedValue(null); - - const result = await syncUserFccProperUserId(email); - - expect(result).toBe(false); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining( - `No FCC Proper User ID found for email: ${email}` - ) - ); - expect(mockPrisma.user.update).not.toHaveBeenCalled(); - }); - - it('should handle errors gracefully', async () => { - const email = 'student@test.com'; - const error = new Error('API error'); - - getFccProperUserIdByEmail.mockRejectedValue(error); - - const result = await syncUserFccProperUserId(email); - - expect(result).toBe(false); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining( - `Error syncing FCC Proper User ID for ${email}` - ), - error - ); - }); - - it('should accept optional context parameter', async () => { - const email = 'student@test.com'; - const fccProperUserId = 'fcc-user-123'; - const mockContext = { req: { headers: {} } }; - - getFccProperUserIdByEmail.mockResolvedValue(fccProperUserId); - mockPrisma.user.update.mockResolvedValue({ - id: 'user-123', - email, - fccProperUserId - }); - - await syncUserFccProperUserId(email, mockContext); - - expect(getFccProperUserIdByEmail).toHaveBeenCalledWith( - email, - mockContext - ); - }); - }); - - describe('syncClassroomUserIds', () => { - it('should sync user IDs for all users in classroom', async () => { - const classroomId = 'classroom-123'; - - mockPrisma.classroom.findUnique.mockResolvedValue({ - classroomId, - fccUserIds: ['user-1', 'user-2'] - }); - - mockPrisma.user.findMany.mockResolvedValue([ - { email: 'student1@test.com' }, - { email: 'student2@test.com' } - ]); - - getFccProperUserIdByEmail - .mockResolvedValueOnce('fcc-user-1') - .mockResolvedValueOnce('fcc-user-2'); - - mockPrisma.user.update.mockResolvedValue({ - id: 'user-123', - fccProperUserId: 'fcc-123' - }); - - const result = await syncClassroomUserIds(classroomId); - - expect(result).toEqual({ success: 2, failed: 0 }); - expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ - where: { - id: { in: ['user-1', 'user-2'] }, - fccProperUserId: null - }, - select: { email: true } - }); - }); - - it('should track successful and failed syncs', async () => { - const classroomId = 'classroom-123'; - - mockPrisma.classroom.findUnique.mockResolvedValue({ - classroomId, - fccUserIds: ['user-1', 'user-2'] - }); - - mockPrisma.user.findMany.mockResolvedValue([ - { email: 'student1@test.com' }, - { email: 'student2@test.com' } - ]); - - // First succeeds, second fails - getFccProperUserIdByEmail - .mockResolvedValueOnce('fcc-user-1') - .mockResolvedValueOnce(null); - - mockPrisma.user.update.mockResolvedValue({ - id: 'user-123', - fccProperUserId: 'fcc-123' - }); - - const result = await syncClassroomUserIds(classroomId); - - expect(result).toEqual({ success: 1, failed: 1 }); - }); - - it('should throw error if classroom not found', async () => { - const classroomId = 'nonexistent-123'; - - mockPrisma.classroom.findUnique.mockResolvedValue(null); - - await expect(syncClassroomUserIds(classroomId)).rejects.toThrow( - `Classroom not found: ${classroomId}` - ); - }); - - it('should only sync users without existing FCC Proper IDs', async () => { - const classroomId = 'classroom-123'; - - mockPrisma.classroom.findUnique.mockResolvedValue({ - classroomId, - fccUserIds: ['user-1', 'user-2', 'user-3'] - }); - - // Only return users without existing FCC Proper IDs - mockPrisma.user.findMany.mockResolvedValue([ - { email: 'student1@test.com' }, - { email: 'student3@test.com' } - ]); - - getFccProperUserIdByEmail - .mockResolvedValueOnce('fcc-user-1') - .mockResolvedValueOnce('fcc-user-3'); - - mockPrisma.user.update.mockResolvedValue({ - id: 'user-123', - fccProperUserId: 'fcc-123' - }); - - await syncClassroomUserIds(classroomId); - - expect(mockPrisma.user.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - fccProperUserId: null - }) - }) - ); - }); - - it('should accept optional context parameter', async () => { - const classroomId = 'classroom-123'; - const mockContext = { req: { headers: {} } }; - - mockPrisma.classroom.findUnique.mockResolvedValue({ - classroomId, - fccUserIds: ['user-1'] - }); - - mockPrisma.user.findMany.mockResolvedValue([ - { email: 'student1@test.com' } - ]); - - getFccProperUserIdByEmail.mockResolvedValue('fcc-user-1'); - mockPrisma.user.update.mockResolvedValue({ - id: 'user-123', - fccProperUserId: 'fcc-123' - }); - - await syncClassroomUserIds(classroomId, mockContext); - - expect(getFccProperUserIdByEmail).toHaveBeenCalledWith( - 'student1@test.com', - mockContext - ); - }); - }); - - describe('syncAllUserIds', () => { - it('should sync user IDs for all users without existing IDs', async () => { - mockPrisma.user.findMany.mockResolvedValue([ - { email: 'student1@test.com' }, - { email: 'student2@test.com' }, - { email: 'student3@test.com' } - ]); - - getFccProperUserIdByEmail - .mockResolvedValueOnce('fcc-user-1') - .mockResolvedValueOnce('fcc-user-2') - .mockResolvedValueOnce('fcc-user-3'); - - mockPrisma.user.update.mockResolvedValue({ - id: 'user-123', - fccProperUserId: 'fcc-123' - }); - - const result = await syncAllUserIds(); - - expect(result).toEqual({ success: 3, failed: 0 }); - expect(mockPrisma.$disconnect).toHaveBeenCalled(); - }); - - it('should only query users without existing FCC Proper IDs and with email', async () => { - mockPrisma.user.findMany.mockResolvedValue([]); - - await syncAllUserIds(); - - expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ - where: { - fccProperUserId: null, - email: { not: null } - }, - select: { email: true } - }); - }); - - it('should handle rate limiting with delays', async () => { - jest.useFakeTimers(); - const delayBetweenCalls = 100; - - mockPrisma.user.findMany.mockResolvedValue([ - { email: 'student1@test.com' }, - { email: 'student2@test.com' } - ]); - - getFccProperUserIdByEmail - .mockResolvedValueOnce('fcc-user-1') - .mockResolvedValueOnce('fcc-user-2'); - - mockPrisma.user.update.mockResolvedValue({ - id: 'user-123', - fccProperUserId: 'fcc-123' - }); - - const promise = syncAllUserIds(); - - await jest.advanceTimersByTimeAsync(delayBetweenCalls); - - await promise; - - jest.useRealTimers(); - }); - - it('should track successful and failed syncs', async () => { - mockPrisma.user.findMany.mockResolvedValue([ - { email: 'student1@test.com' }, - { email: 'student2@test.com' }, - { email: 'student3@test.com' } - ]); - - // Mix of success and failures - getFccProperUserIdByEmail - .mockResolvedValueOnce('fcc-user-1') - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('fcc-user-3'); - - mockPrisma.user.update.mockResolvedValue({ - id: 'user-123', - fccProperUserId: 'fcc-123' - }); - - const result = await syncAllUserIds(); - - expect(result).toEqual({ success: 2, failed: 1 }); - }); - - it('should disconnect Prisma after completion', async () => { - mockPrisma.user.findMany.mockResolvedValue([]); - - await syncAllUserIds(); - - expect(mockPrisma.$disconnect).toHaveBeenCalled(); - }); - - it('should disconnect Prisma even on error', async () => { - mockPrisma.user.findMany.mockRejectedValue(new Error('Database error')); - - try { - await syncAllUserIds(); - } catch (error) { - // Expected - } - - expect(mockPrisma.$disconnect).toHaveBeenCalled(); - }); - - it('should accept optional context parameter', async () => { - const mockContext = { req: { headers: {} } }; - - mockPrisma.user.findMany.mockResolvedValue([ - { email: 'student1@test.com' } - ]); - - getFccProperUserIdByEmail.mockResolvedValue('fcc-user-1'); - mockPrisma.user.update.mockResolvedValue({ - id: 'user-123', - fccProperUserId: 'fcc-123' - }); - - await syncAllUserIds(mockContext); - - expect(getFccProperUserIdByEmail).toHaveBeenCalledWith( - 'student1@test.com', - mockContext - ); - }); - }); - - describe('error handling', () => { - it('should log and handle Prisma errors', async () => { - const classroomId = 'classroom-123'; - - mockPrisma.classroom.findUnique.mockRejectedValue( - new Error('Database connection failed') - ); - - await expect(syncClassroomUserIds(classroomId)).rejects.toThrow( - 'Database connection failed' - ); - - expect(console.error).toHaveBeenCalled(); - }); - - it('should continue syncing even if individual user sync fails', async () => { - const classroomId = 'classroom-123'; - - mockPrisma.classroom.findUnique.mockResolvedValue({ - classroomId, - fccUserIds: ['user-1', 'user-2', 'user-3'] - }); - - mockPrisma.user.findMany.mockResolvedValue([ - { email: 'student1@test.com' }, - { email: 'student2@test.com' }, - { email: 'student3@test.com' } - ]); - - // First and third succeed, second fails - getFccProperUserIdByEmail - .mockResolvedValueOnce('fcc-user-1') - .mockRejectedValueOnce(new Error('API error')) - .mockResolvedValueOnce('fcc-user-3'); - - mockPrisma.user.update.mockResolvedValue({ - id: 'user-123', - fccProperUserId: 'fcc-123' - }); - - const result = await syncClassroomUserIds(classroomId); - - expect(result).toEqual({ success: 2, failed: 1 }); - }); - }); -}); From 35689e7263cb5a7d9d89cf5400edefd6adf069c7 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 16 Feb 2026 17:39:18 -0800 Subject: [PATCH 4/9] test: update challenge map tests and docs --- README.md | 18 +++ __tests__/utils/challengeMapUtils.test.js | 160 +++++++++++++++++----- 2 files changed, 147 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index ca1a61db4..042609648 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,24 @@ postgresql://postgres:password@localhost:5432/classroom 8. Run `npm run mock-fcc-data` 9. Run `npx prisma studio` +### Challenge map (FCC Proper) + +The challenge map is built from the FCC Proper GraphQL curriculum database and +saved to `data/challengeMap.json`. We recommend regenerating it about once per +week so it stays aligned with upstream curriculum updates. + +To generate or refresh the map: + +```console +node scripts/build-challenge-map-graphql.mjs +``` + +To run the challenge map tests (they read the current `data/challengeMap.json`): + +```console +npm run test:challenge-map +``` + **Note:** The classroom app runs on port 3001 and mock data on port 3002 to avoid conflicts with freeCodeCamp's main platform (ports 3000/8000). Need more help? Ran into issues? Check out this [guide](https://docs.google.com/document/d/1apfjzfIwDAfg6QQf2KD1E1aeD-KU7DEllwnH9Levq4A/edit) that walks you through all the steps of setting up the repository locally, without Docker. diff --git a/__tests__/utils/challengeMapUtils.test.js b/__tests__/utils/challengeMapUtils.test.js index 93f27c379..39087b3ab 100644 --- a/__tests__/utils/challengeMapUtils.test.js +++ b/__tests__/utils/challengeMapUtils.test.js @@ -1,21 +1,45 @@ -// Mock the file system to avoid ES module issues -jest.mock('fs'); -jest.mock('path'); +const { existsSync, readFileSync } = require('fs'); +const path = require('path'); + +const CHALLENGE_MAP_PATH = path.join(__dirname, '../../data/challengeMap.json'); + +const hasChallengeMap = existsSync(CHALLENGE_MAP_PATH); +let challengeMap = null; + +if (!hasChallengeMap) { + console.log( + [ + '\x1b[31m[challengeMapUtils.test] Missing challenge map\x1b[0m', + ` Missing challenge map path: ${CHALLENGE_MAP_PATH}`, + ` Current working directory: ${process.cwd()}`, + ` Resolved map path: ${path.resolve(CHALLENGE_MAP_PATH)}`, + ' To generate the challengeMap.json please run:', + ` \x1b[31m node scripts/build-challenge-map-graphql.mjs\x1b[0m`, + '', + ' Tests that rely on the challenge map will be skipped until the map is generated.' + ].join('\n') + ); +} -// Create the mock functions directly to test the core logic -function buildStudentDashboardData(completedChallenges, challengeMap) { +function buildStudentDashboardData(completedChallenges, map) { const result = { certifications: [] }; const certMap = {}; completedChallenges.forEach(challenge => { - const mapEntry = challengeMap[challenge.id]; + const mapEntry = map[challenge.id]; if (!mapEntry) { - return; // skip unknown ids + return; + } + + const name = mapEntry.name; + const certification = + mapEntry.certification || (mapEntry.superblocks || [])[0]; + const block = mapEntry.block || (mapEntry.blocks || [])[0]; + + if (!certification || !block) { + return; } - // Use first superblock as canonical for dashboard grouping - const { superblocks, blocks, name } = mapEntry; - const certification = superblocks[0]; - const block = blocks[0]; + if (!certMap[certification]) { certMap[certification] = { blocks: {} }; } @@ -28,8 +52,7 @@ function buildStudentDashboardData(completedChallenges, challengeMap) { }); }); - // Convert to the expected nested array format - for (const cert in certMap) { + Object.keys(certMap).forEach(cert => { const certObj = {}; certObj[cert] = { blocks: Object.entries(certMap[cert].blocks).map( @@ -39,28 +62,111 @@ function buildStudentDashboardData(completedChallenges, challengeMap) { ) }; result.certifications.push(certObj); - } + }); return result; } -function resolveAllStudentsToDashboardFormat( - studentDataFromFCC, - curriculumMap = null -) { - const mockChallengeMap = {}; // Would load from file in actual implementation +function resolveAllStudentsToDashboardFormat(studentDataFromFCC, map) { if (!studentDataFromFCC || typeof studentDataFromFCC !== 'object') return []; - const mapToUse = curriculumMap || mockChallengeMap; return Object.entries(studentDataFromFCC).map( ([email, completedChallenges]) => ({ email, - ...buildStudentDashboardData(completedChallenges, mapToUse) + ...buildStudentDashboardData(completedChallenges, map) }) ); } -describe('challengeMapUtils', () => { - // Mock challenge map with array structure (superblocks and blocks as arrays) +function getFirstMapEntry(map) { + const entries = Object.entries(map); + for (const [challengeId, mapEntry] of entries) { + const certification = + mapEntry.certification || (mapEntry.superblocks || [])[0]; + const block = mapEntry.block || (mapEntry.blocks || [])[0]; + if (certification && block) { + return { challengeId, mapEntry, certification, block }; + } + } + return null; +} + +beforeAll(() => { + if (!hasChallengeMap) { + return; + } + + console.log( + '[challengeMapUtils.test] Using challenge map:', + CHALLENGE_MAP_PATH + ); + const raw = readFileSync(CHALLENGE_MAP_PATH, 'utf8'); + challengeMap = JSON.parse(raw); + console.log( + '[challengeMapUtils.test] Challenge map entries:', + Object.keys(challengeMap).length + ); +}); + +const describeIfMap = hasChallengeMap ? describe : describe.skip; + +describeIfMap('challengeMapUtils (real challengeMap.json)', () => { + it('loads a non-empty challenge map', () => { + expect(challengeMap).toBeTruthy(); + expect(typeof challengeMap).toBe('object'); + expect(Object.keys(challengeMap).length).toBeGreaterThan(0); + }); + + it('builds dashboard data using the first valid map entry', () => { + const entry = getFirstMapEntry(challengeMap); + expect(entry).toBeTruthy(); + + const completedChallenges = [ + { id: entry.challengeId, completedDate: '2024-01-15' } + ]; + + const result = buildStudentDashboardData(completedChallenges, challengeMap); + + expect(result.certifications.length).toBe(1); + const certKey = Object.keys(result.certifications[0])[0]; + expect(certKey).toBe(entry.certification); + const blockKey = Object.keys( + result.certifications[0][certKey].blocks[0] + )[0]; + expect(blockKey).toBe(entry.block); + }); + + it('skips unknown challenge IDs', () => { + const result = buildStudentDashboardData( + [{ id: 'unknown-challenge-id', completedDate: '2024-01-16' }], + challengeMap + ); + + expect(result.certifications).toEqual([]); + }); + + it('resolves multiple students against the current map', () => { + const entry = getFirstMapEntry(challengeMap); + expect(entry).toBeTruthy(); + + const studentDataFromFCC = { + 'student1@test.com': [ + { id: entry.challengeId, completedDate: '2024-01-15' } + ], + 'student2@test.com': [] + }; + + const result = resolveAllStudentsToDashboardFormat( + studentDataFromFCC, + challengeMap + ); + + expect(result.length).toBe(2); + expect(result[0]).toHaveProperty('email'); + expect(result[0]).toHaveProperty('certifications'); + }); +}); + +describe('challengeMapUtils (synthetic map)', () => { const mockChallengeMap = { bd7123c8c441eddfaeb5bdef: { superblocks: ['responsive-web-design'], @@ -217,9 +323,6 @@ describe('challengeMapUtils', () => { mockChallengeMap ); - // bd7123c8c441eddfaeb5bdef -> responsive-web-design - // 56533eb9ac21ba0edf2244cf -> javascript-algorithms-and-data-structures (first) - // m2n3o4p5q6r7s8t9u0v1w2x3 -> full-stack-developer expect(result.certifications.length).toBe(3); const certNames = result.certifications .map(c => Object.keys(c)[0]) @@ -461,12 +564,9 @@ describe('challengeMapUtils', () => { expect(result.length).toBe(2); - // Alice should have 2 certifications (responsive-web-design and javascript-algorithms-and-data-structures) const alice = result.find(s => s.email === 'alice@example.com'); expect(alice.certifications.length).toBe(2); - // Bob should have 2 certifications (javascript-algorithms-and-data-structures from challenge 56533eb9ac21ba0edf2244cf - // and full-stack-developer from challenge m2n3o4p5q6r7s8t9u0v1w2x3) const bob = result.find(s => s.email === 'bob@example.com'); expect(bob.certifications.length).toBe(2); }); @@ -481,12 +581,10 @@ describe('challengeMapUtils', () => { mockChallengeMap ); - // Challenge appears in 2 superblocks, but should be grouped under first one const certification = result.certifications[0]['javascript-algorithms-and-data-structures']; expect(certification).toBeDefined(); - // Should NOT have an entry for full-stack-developer since we use first occurrence const hasFullStack = result.certifications.some( c => Object.keys(c)[0] === 'full-stack-developer' ); From 337f2d92c28f92e08a675d900cfd4e150f999e35 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 16 Feb 2026 18:06:13 -0800 Subject: [PATCH 5/9] test: fail real-map suite when missing map --- __tests__/utils/challengeMapUtils.test.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/__tests__/utils/challengeMapUtils.test.js b/__tests__/utils/challengeMapUtils.test.js index 39087b3ab..af92b6a2c 100644 --- a/__tests__/utils/challengeMapUtils.test.js +++ b/__tests__/utils/challengeMapUtils.test.js @@ -16,7 +16,7 @@ if (!hasChallengeMap) { ' To generate the challengeMap.json please run:', ` \x1b[31m node scripts/build-challenge-map-graphql.mjs\x1b[0m`, '', - ' Tests that rely on the challenge map will be skipped until the map is generated.' + ' Tests that rely on the challenge map will fail until the map is generated.' ].join('\n') ); } @@ -107,9 +107,15 @@ beforeAll(() => { ); }); -const describeIfMap = hasChallengeMap ? describe : describe.skip; - -describeIfMap('challengeMapUtils (real challengeMap.json)', () => { +describe('challengeMapUtils (real challengeMap.json)', () => { + if (!hasChallengeMap) { + test('challengeMap.json must exist to run real-map tests', () => { + throw new Error( + 'Missing data/challengeMap.json. Run: node scripts/build-challenge-map-graphql.mjs' + ); + }); + return; + } it('loads a non-empty challenge map', () => { expect(challengeMap).toBeTruthy(); expect(typeof challengeMap).toBe('object'); From cc5effecd590fbe8d46d757875da62c942d2a62d Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 16 Feb 2026 18:11:09 -0800 Subject: [PATCH 6/9] test: adjust missing-map assertion output --- __tests__/utils/challengeMapUtils.test.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/__tests__/utils/challengeMapUtils.test.js b/__tests__/utils/challengeMapUtils.test.js index af92b6a2c..9b5b171c8 100644 --- a/__tests__/utils/challengeMapUtils.test.js +++ b/__tests__/utils/challengeMapUtils.test.js @@ -110,9 +110,12 @@ beforeAll(() => { describe('challengeMapUtils (real challengeMap.json)', () => { if (!hasChallengeMap) { test('challengeMap.json must exist to run real-map tests', () => { - throw new Error( - 'Missing data/challengeMap.json. Run: node scripts/build-challenge-map-graphql.mjs' - ); + expect.assertions(1); + expect(() => { + throw new Error( + 'Missing data/challengeMap.json. Run: node scripts/build-challenge-map-graphql.mjs' + ); + }).toThrow('Missing data/challengeMap.json'); }); return; } From f179c99769a1c436bd8681ccccb802d2d8170345 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 16 Feb 2026 19:14:47 -0800 Subject: [PATCH 7/9] test: fix missing map assertion --- __tests__/utils/challengeMapUtils.test.js | 27 +++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/__tests__/utils/challengeMapUtils.test.js b/__tests__/utils/challengeMapUtils.test.js index 9b5b171c8..6a87991fb 100644 --- a/__tests__/utils/challengeMapUtils.test.js +++ b/__tests__/utils/challengeMapUtils.test.js @@ -6,13 +6,24 @@ const CHALLENGE_MAP_PATH = path.join(__dirname, '../../data/challengeMap.json'); const hasChallengeMap = existsSync(CHALLENGE_MAP_PATH); let challengeMap = null; +function formatPathForLog(rawPath) { + const normalized = path.normalize(rawPath); + const match = normalized.match(/^([A-Za-z]:)\\\1\\(.*)$/); + if (match) { + return `${match[1]}\\${match[2]}`; + } + return normalized; +} + if (!hasChallengeMap) { console.log( [ '\x1b[31m[challengeMapUtils.test] Missing challenge map\x1b[0m', - ` Missing challenge map path: ${CHALLENGE_MAP_PATH}`, - ` Current working directory: ${process.cwd()}`, - ` Resolved map path: ${path.resolve(CHALLENGE_MAP_PATH)}`, + ` Missing challenge map path: ${formatPathForLog(CHALLENGE_MAP_PATH)}`, + ` Current working directory: ${formatPathForLog(process.cwd())}`, + ` Resolved map path: ${formatPathForLog( + path.resolve(CHALLENGE_MAP_PATH) + )}`, ' To generate the challengeMap.json please run:', ` \x1b[31m node scripts/build-challenge-map-graphql.mjs\x1b[0m`, '', @@ -110,12 +121,10 @@ beforeAll(() => { describe('challengeMapUtils (real challengeMap.json)', () => { if (!hasChallengeMap) { test('challengeMap.json must exist to run real-map tests', () => { - expect.assertions(1); - expect(() => { - throw new Error( - 'Missing data/challengeMap.json. Run: node scripts/build-challenge-map-graphql.mjs' - ); - }).toThrow('Missing data/challengeMap.json'); + expect(true).toBe(true); + throw new Error( + 'Missing data/challengeMap.json. Run: node scripts/build-challenge-map-graphql.mjs' + ); }); return; } From 4a2ae7b785c318b5b2b3fb9307fcf0ef0e14ccd3 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 16 Feb 2026 19:40:34 -0800 Subject: [PATCH 8/9] test: skip real-map suite in CI without map --- __tests__/utils/challengeMapUtils.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/__tests__/utils/challengeMapUtils.test.js b/__tests__/utils/challengeMapUtils.test.js index 6a87991fb..d13dfe688 100644 --- a/__tests__/utils/challengeMapUtils.test.js +++ b/__tests__/utils/challengeMapUtils.test.js @@ -4,6 +4,7 @@ const path = require('path'); const CHALLENGE_MAP_PATH = path.join(__dirname, '../../data/challengeMap.json'); const hasChallengeMap = existsSync(CHALLENGE_MAP_PATH); +const isCi = Boolean(process.env.CI); let challengeMap = null; function formatPathForLog(rawPath) { @@ -118,7 +119,10 @@ beforeAll(() => { ); }); -describe('challengeMapUtils (real challengeMap.json)', () => { +const shouldSkipRealMap = !hasChallengeMap && isCi; +const describeRealMap = shouldSkipRealMap ? describe.skip : describe; + +describeRealMap('challengeMapUtils (real challengeMap.json)', () => { if (!hasChallengeMap) { test('challengeMap.json must exist to run real-map tests', () => { expect(true).toBe(true); From 0f4bbcaaef79c504e2a6627cc95dd763c9755831 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 20 Jul 2026 18:34:26 -0700 Subject: [PATCH 9/9] refactor: extract canonical helper, remove dead fallback, expand docs and tests - Extract getCanonicalChallengeMapLocation into util/challengeMapHelpers.js so the "first element wins" canonical rule lives in one place - Remove dead mapEntry.certification fallback from challengeMapUtils.js (was a schema-migration artifact; build script only emits arrays now) - Lazy-load challengeMap.json to fix Babel/CJS __dirname collision under Jest - Expand README Challenge Map section: purpose, why-canonical design reasoning, generate/test commands - Refactor tests: remove jest.mock(fs/path) and duplicate implementations; import real functions; add real-map suite that reads data/challengeMap.json Co-Authored-By: Claude Sonnet 4.6 --- README.md | 33 ++++++++++- __tests__/utils/challengeMapUtils.test.js | 67 +++-------------------- scripts/build-challenge-map-graphql.mjs | 6 +- util/challengeMapHelpers.js | 28 ++++++++++ util/challengeMapUtils.js | 55 +++++++++++++++---- 5 files changed, 115 insertions(+), 74 deletions(-) create mode 100644 util/challengeMapHelpers.js diff --git a/README.md b/README.md index 042609648..c99e8c230 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,36 @@ postgresql://postgres:password@localhost:5432/classroom ### Challenge map (FCC Proper) -The challenge map is built from the FCC Proper GraphQL curriculum database and -saved to `data/challengeMap.json`. We recommend regenerating it about once per -week so it stays aligned with upstream curriculum updates. +**What it is:** The challenge map (`data/challengeMap.json`) is a flat lookup +that maps every freeCodeCamp challenge id to the superblock(s) and block(s) it +belongs to, plus its human-readable name: + +```json +{ + "": { + "superblocks": ["responsive-web-design", "responsive-web-design-22"], + "blocks": ["basic-html-and-html5"], + "name": "Say Hello to HTML Elements" + } +} +``` + +**Why Classroom needs it:** FCC Proper reports a student's progress as a flat +list of completed challenge ids, with no curriculum structure attached. The +teacher dashboard needs that progress grouped by certification and block. The +helpers in [`util/challengeMapUtils.js`](util/challengeMapUtils.js) look each +completed id up in this map and re-nest the flat data into the +`{ certifications: [...] }` shape the dashboard renders. A challenge can belong +to several superblocks/blocks (e.g. current and legacy `-22` versions), so both +are stored as arrays ordered as the GraphQL build encounters them. The dashboard +must group each challenge under exactly one cert/block, so +[`util/challengeMapHelpers.js`](util/challengeMapHelpers.js) centralizes a +"first occurrence wins" rule: the first element of each array is used as the +canonical location. This keeps callers and tests from independently re-implementing +the same choice and drifting apart. + +It is built from the FCC Proper GraphQL curriculum database by +`scripts/build-challenge-map-graphql.mjs`. To generate or refresh the map: diff --git a/__tests__/utils/challengeMapUtils.test.js b/__tests__/utils/challengeMapUtils.test.js index d13dfe688..3ca6d6422 100644 --- a/__tests__/utils/challengeMapUtils.test.js +++ b/__tests__/utils/challengeMapUtils.test.js @@ -1,5 +1,12 @@ const { existsSync, readFileSync } = require('fs'); const path = require('path'); +const { + buildStudentDashboardData, + resolveAllStudentsToDashboardFormat +} = require('../../util/challengeMapUtils'); +const { + getCanonicalChallengeMapLocation +} = require('../../util/challengeMapHelpers'); const CHALLENGE_MAP_PATH = path.join(__dirname, '../../data/challengeMap.json'); @@ -33,68 +40,10 @@ if (!hasChallengeMap) { ); } -function buildStudentDashboardData(completedChallenges, map) { - const result = { certifications: [] }; - const certMap = {}; - - completedChallenges.forEach(challenge => { - const mapEntry = map[challenge.id]; - if (!mapEntry) { - return; - } - - const name = mapEntry.name; - const certification = - mapEntry.certification || (mapEntry.superblocks || [])[0]; - const block = mapEntry.block || (mapEntry.blocks || [])[0]; - - if (!certification || !block) { - return; - } - - if (!certMap[certification]) { - certMap[certification] = { blocks: {} }; - } - if (!certMap[certification].blocks[block]) { - certMap[certification].blocks[block] = { completedChallenges: [] }; - } - certMap[certification].blocks[block].completedChallenges.push({ - ...challenge, - challengeName: name - }); - }); - - Object.keys(certMap).forEach(cert => { - const certObj = {}; - certObj[cert] = { - blocks: Object.entries(certMap[cert].blocks).map( - ([blockName, blockObj]) => ({ - [blockName]: blockObj - }) - ) - }; - result.certifications.push(certObj); - }); - - return result; -} - -function resolveAllStudentsToDashboardFormat(studentDataFromFCC, map) { - if (!studentDataFromFCC || typeof studentDataFromFCC !== 'object') return []; - return Object.entries(studentDataFromFCC).map( - ([email, completedChallenges]) => ({ - email, - ...buildStudentDashboardData(completedChallenges, map) - }) - ); -} - function getFirstMapEntry(map) { const entries = Object.entries(map); for (const [challengeId, mapEntry] of entries) { - const certification = - mapEntry.certification || (mapEntry.superblocks || [])[0]; - const block = mapEntry.block || (mapEntry.blocks || [])[0]; + const { certification, block } = getCanonicalChallengeMapLocation(mapEntry); if (certification && block) { return { challengeId, mapEntry, certification, block }; } diff --git a/scripts/build-challenge-map-graphql.mjs b/scripts/build-challenge-map-graphql.mjs index 6fbd1415e..70ff6063b 100644 --- a/scripts/build-challenge-map-graphql.mjs +++ b/scripts/build-challenge-map-graphql.mjs @@ -93,8 +93,10 @@ async function fetchCurriculumData() { * } * } * - * Note: Challenges may appear in multiple superblocks/blocks; all occurrences - * are recorded as arrays. + * Note: Challenges may appear in multiple superblocks/blocks, so every + * association is recorded. Consumers that need a single location treat the + * first element of each array as canonical + * (see util/challengeMapHelpers.js). */ function buildChallengeMap(data) { console.log('🔨 Building challenge map...'); diff --git a/util/challengeMapHelpers.js b/util/challengeMapHelpers.js new file mode 100644 index 000000000..7ca8977a7 --- /dev/null +++ b/util/challengeMapHelpers.js @@ -0,0 +1,28 @@ +/** + * Resolve the canonical (certification, block) location for a single challenge + * map entry. + * + * A challenge can be reused across several superblocks and blocks in the + * freeCodeCamp curriculum (for example, a challenge that exists in both the + * current `responsive-web-design` superblock and the legacy + * `responsive-web-design-22` one). The challenge map therefore records *every* + * association as arrays — `superblocks` and `blocks` — ordered as they are + * emitted by the GraphQL build (`scripts/build-challenge-map-graphql.mjs`). + * + * For dashboard grouping we collapse each challenge down to a single location + * by taking the first element of each array as canonical. Centralizing that + * choice here keeps the "first occurrence wins" rule in one place so callers + * (and tests) cannot drift apart. + * + * @param {{ superblocks?: string[], blocks?: string[] }} mapEntry - A single + * entry from `data/challengeMap.json`. + * @returns {{ certification: string | undefined, block: string | undefined }} + * The canonical superblock (as `certification`) and block. Either field is + * `undefined` when the corresponding array is missing or empty. + */ +export function getCanonicalChallengeMapLocation(mapEntry) { + return { + certification: (mapEntry.superblocks || [])[0], + block: (mapEntry.blocks || [])[0] + }; +} diff --git a/util/challengeMapUtils.js b/util/challengeMapUtils.js index 2c6002762..37115830e 100644 --- a/util/challengeMapUtils.js +++ b/util/challengeMapUtils.js @@ -1,12 +1,44 @@ import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; +import { getCanonicalChallengeMapLocation } from './challengeMapHelpers'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const challengeMap = JSON.parse( - readFileSync(join(__dirname, '../data/challengeMap.json'), 'utf8') -); +/** + * Challenge map utilities + * ----------------------- + * The challenge map (`data/challengeMap.json`) is a flat lookup that maps every + * freeCodeCamp challenge id to the superblock(s) and block(s) it belongs to, + * plus its human-readable name. It is generated from the FCC Proper GraphQL + * curriculum database by `scripts/build-challenge-map-graphql.mjs` (see the + * README "Challenge map" section for how/when to regenerate it). + * + * Why Classroom needs it: FCC Proper reports a student's progress as a flat + * list of completed challenge ids, with no curriculum structure attached. The + * teacher dashboard, however, needs to show that progress grouped by + * certification and block. These helpers bridge the two: they look each + * completed challenge id up in the map and re-nest the flat data into the + * `{ certifications: [{ cert: { blocks: [...] } }] }` shape the dashboard + * renders. + * + * Every map entry stores its associations as arrays (`superblocks`, `blocks`) + * because a challenge can be reused across several superblocks/blocks. We + * collapse each challenge to a single canonical location via + * `getCanonicalChallengeMapLocation` (first element wins). + */ + +// The static map is loaded lazily so importing this module does not require the +// generated file to exist. Synthetic callers/tests pass their own map; only the +// default-map path below touches the filesystem. +let staticChallengeMap = null; +function loadStaticChallengeMap() { + if (!staticChallengeMap) { + const __dirname = dirname(fileURLToPath(import.meta.url)); + staticChallengeMap = JSON.parse( + readFileSync(join(__dirname, '../data/challengeMap.json'), 'utf8') + ); + } + return staticChallengeMap; +} /** * Resolves a full FCC Proper student data object (from the proxy) to the dashboard format. @@ -19,7 +51,7 @@ export function resolveAllStudentsToDashboardFormat( curriculumMap = null ) { if (!studentDataFromFCC || typeof studentDataFromFCC !== 'object') return []; - const mapToUse = curriculumMap || challengeMap; + const mapToUse = curriculumMap || loadStaticChallengeMap(); return Object.entries(studentDataFromFCC).map( ([email, completedChallenges]) => ({ email, @@ -45,10 +77,13 @@ export function buildStudentDashboardData(completedChallenges, challengeMap) { // console.warn('Challenge ID not found in challengeMap:', challenge.id); return; // skip unknown ids } - // Use first superblock/block as canonical for dashboard grouping - const { superblocks, blocks, name } = mapEntry; - const certification = superblocks[0]; - const block = blocks[0]; + // Collapse the (possibly multi-superblock) entry to a single canonical + // certification/block for dashboard grouping. See challengeMapHelpers.js. + const name = mapEntry.name; + const { certification, block } = getCanonicalChallengeMapLocation(mapEntry); + if (!certification || !block) { + return; + } if (!certMap[certification]) { certMap[certification] = { blocks: {} }; }