Skip to content
97 changes: 96 additions & 1 deletion __tests__/utils/fccApi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jest.mock('../../util/challengeMapUtils', () => ({
)
}));

const { fetchUserData } = require('../../util/fcc-api');
const { fetchUserIdByEmail, fetchUserData } = require('../../util/fcc-api');
const {
fetchClassroomStudentData
} = require('../../util/student/fetchStudentData');
Expand All @@ -31,6 +31,101 @@ describe('FCC API handshake', () => {
delete process.env.TPA_API_BEARER_TOKEN;
});

// ---------------------------------------------------------------------------
// fetchUserIdByEmail — /apps/classroom/get-user-id
// ---------------------------------------------------------------------------
describe('fetchUserIdByEmail', () => {
it('POSTs to the correct get-user-id endpoint with the email in the body', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ userId: 'abc123' })
});

await fetchUserIdByEmail('student@example.com');

expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3000/apps/classroom/get-user-id',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'student@example.com' })
})
);
});

it('includes the Bearer token in the Authorization header', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ userId: 'abc123' })
});

await fetchUserIdByEmail('student@example.com');

const [, options] = global.fetch.mock.calls[0];
expect(options.headers['Authorization']).toBe('Bearer test-bearer-token');
expect(options.headers['Content-Type']).toBe('application/json');
});

it('returns the userId from the response', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ userId: 'user_objectid_abc123' })
});

const result = await fetchUserIdByEmail('student@example.com');

expect(result).toEqual({ userId: 'user_objectid_abc123' });
});

it('returns an empty string userId when the user has not opted in to classroom mode', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ userId: '' })
});

const result = await fetchUserIdByEmail('nooptin@example.com');

expect(result.userId).toBe('');
});

it('throws when TPA_API_BEARER_TOKEN env var is missing', async () => {
delete process.env.TPA_API_BEARER_TOKEN;

await expect(fetchUserIdByEmail('student@example.com')).rejects.toThrow(
'TPA_API_BEARER_TOKEN is not set'
);

expect(global.fetch).not.toHaveBeenCalled();
});

it('throws when the FCC API returns a non-ok response', async () => {
global.fetch.mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: async () => ({ error: 'Invalid token' })
});

await expect(fetchUserIdByEmail('student@example.com')).rejects.toThrow(
'Failed to look up fCC user ID'
);
});

it('throws when the FCC API returns a 500 with no parseable JSON body', async () => {
global.fetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: async () => {
throw new SyntaxError('not json');
}
});

await expect(fetchUserIdByEmail('student@example.com')).rejects.toThrow(
'Failed to look up fCC user ID'
);
});
});

// ---------------------------------------------------------------------------
// fetchUserData — /apps/classroom/get-user-data
// ---------------------------------------------------------------------------
Expand Down
153 changes: 108 additions & 45 deletions pages/api/student_email_join.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,79 +3,142 @@
import { authOptions } from './auth/[...nextauth]';
import { fetchUserIdByEmail } from '../../util/fcc-api';

export default async function handle(req, res) {
// unstable_getServerSession is recommended here: https://next-auth.js.org/configuration/nextjs
const session = await unstable_getServerSession(req, res, authOptions);

if (req.method !== 'PUT') {
return res.status(405).end();
return res.status(405).json({
success: false,
error: 'METHOD_NOT_ALLOWED',
message: 'Only PUT requests are allowed.'
});
}

if (!session) {
return res.status(403).end();
if (!session?.user?.email) {
return res.status(403).json({
success: false,
error: 'NOT_AUTHENTICATED',
message: 'You must be signed in to connect to a classroom.'
});
}

const body = req.body || {};
const classroomId = body.classroomId || body.join?.[0] || body.join;

if (!classroomId || typeof classroomId !== 'string') {
return res.status(400).json({
success: false,
error: 'INVALID_CLASSROOM_ID',
message: 'A valid classroomId is required.'
});
}

const body = req.body;
// Grab user info here
const userInfo = await prisma.user.findUnique({
where: {
email: session.user.email
},
select: {
id: true,
role: true,
fccProperUserId: true
}
});
// Grab class info here
const checkClass = await prisma.classroom.findUniqueOrThrow({

if (!userInfo) {
return res.status(404).json({
success: false,
error: 'USER_NOT_FOUND',
message: 'Could not find your Classroom user record.'
});
}

const classroom = await prisma.classroom.findUnique({
where: {
classroomId: body.join[0]
classroomId
},
select: {
fccUserIds: true
}
});
const existsInClassroom = checkClass.fccUserIds.includes(userInfo.id);
if (existsInClassroom) {
return res.status(409).end();

if (!classroom) {
return res.status(404).json({
success: false,
error: 'CLASSROOM_NOT_FOUND',
message: 'We could not find that classroom.'
});
}

let fccProperUserId = '';
try {
const { userId } = await fetchUserIdByEmail(session.user.email);
fccProperUserId = userId?.trim() || '';
} catch (err) {
// fetchUserIdByEmail threw — the FCC API returned a non-ok response
// (e.g. 401 bad token, 500 server error, or a network failure).
return res.status(502).json({
success: false,
error: 'FCC_ID_NOT_FOUND',
message:
'Email not found on FCC Proper or Classroom permission is not enabled.'
});
}
// TODO: Once we allow multiple teachers inside of a classroom, make sure that the teachers
// are placed inside of the teacher array rather than as a regular student
else if (userInfo.role === 'NONE') {
// This runs only when a new user attempts to join a classroom.
await prisma.user.update({
where: {
email: session.user.email
},
data: {
role: 'STUDENT'
}

// Separate from the error above: the FCC API returned 200 but with an empty
// userId, which means the account exists but the user has not enabled
// Classroom mode on their fCC account. fetchUserIdByEmail does not throw in
// this case, so the try/catch above does not catch it.
if (!fccProperUserId) {
return res.status(502).json({
success: false,
error: 'FCC_ID_NOT_FOUND',
message:
'Email not found on FCC Proper or Classroom permission is not enabled.'
});
}
// Resolve fCC Proper user ID if not already linked
if (!userInfo.fccProperUserId) {
try {
const { userId } = await fetchUserIdByEmail(session.user.email);
if (userId) {
await prisma.user.update({
where: { email: session.user.email },
data: { fccProperUserId: userId }
});
}
} catch (err) {
// Don't block the join — fCC ID can be resolved later
console.error('Failed to resolve fCC user ID:', err);
}

const updates = [];

if (
userInfo.fccProperUserId !== fccProperUserId ||
userInfo.role === 'NONE'
) {
updates.push(
prisma.user.update({
where: {
email: session.user.email
},
data: {
fccProperUserId,
...(userInfo.role === 'NONE' ? { role: 'STUDENT' } : {})
}
})
);
}

// Update classroom with user id
await prisma.classroom.update({
where: {
classroomId: body.join[0]
},
data: {
fccUserIds: { push: userInfo.id }
}
if (!classroom.fccUserIds.includes(userInfo.id)) {
updates.push(
prisma.classroom.update({
where: {
classroomId
},
data: {
fccUserIds: { push: userInfo.id }
}
})
);
}

if (updates.length > 0) {
await prisma.$transaction(updates);
}

return res.status(200).json({
success: true,
fccProperUserId,
classroomAppUserId: userInfo.id,
alreadyLinked: userInfo.fccProperUserId === fccProperUserId,
alreadyInRoster: classroom.fccUserIds.includes(userInfo.id)
});
res.status(200).end();
}
Loading